Bug#926507: unblock: mlbstreamer/0.0.11.dev0+git20190330-1
Package: release.debian.org
Severity: normal
User: release.debian.org@packages.debian.org
Usertags: unblock
Dear Release Masters,
#926165 describes how mlbstreamer 0.0.10-3, as present in testing, is
completely non-fonctional due to recent changes in the online service
it targets.
Version 0.0.11.dev0+git20190330-1 fixes that, but of course that's a new
upstream release and the debdiff (attached) is quite large. On the plus
side, it doesn't require new dependencies, and mlbstreamer is a leaf
package with no reverse-dependencies.
Do you think maybe it could be unblocked ? If you consider this a bad
idea, no worries :)
unblock mlbstreamer/0.0.11.dev0+git20190330-1
-- System Information:
Debian Release: buster/sid
APT prefers unstable
APT policy: (500, 'unstable'), (1, 'experimental')
Architecture: amd64 (x86_64)
Foreign Architectures: i386
Kernel: Linux 4.19.0-3-amd64 (SMP w/36 CPU cores)
Kernel taint flags: TAINT_PROPRIETARY_MODULE, TAINT_DIE, TAINT_OOT_MODULE, TAINT_UNSIGNED_MODULE
Locale: LANG=en_US.UTF-8, LC_CTYPE=en_US.UTF-8 (charmap=UTF-8), LANGUAGE=en_US.UTF-8 (charmap=UTF-8)
Shell: /bin/sh linked to /usr/bin/dash
Init: systemd (via /run/systemd/system)
LSM: AppArmor: enabled
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index 8e2cc48..a734308 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,7 +1,7 @@
[bumpversion]
commit = True
tag = True
-current_version = 0.0.10
+current_version = 0.0.11.dev0
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(\.(?P<release>[a-z]+)(?P<dev>\d+))?
serialize =
{major}.{minor}.{patch}.{release}{dev}
diff --git a/debian/NEWS b/debian/NEWS
new file mode 100644
index 0000000..e38ffe3
--- /dev/null
+++ b/debian/NEWS
@@ -0,0 +1,9 @@
+mlbstreamer (0.0.11.dev0+git20190330-1) unstable; urgency=medium
+
+ The configuration file format has changed, please update your
+ ~/.config/mlbstreamer/config according to the example in
+ /usr/share/doc/mlbstreamer/config.yaml.sample.
+
+ -- Sebastien Delafond <seb@debian.org> Mon, 01 Apr 2019 10:37:01 +0200
+
+
diff --git a/debian/changelog b/debian/changelog
index f7e87df..2366f15 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,12 @@
+mlbstreamer (0.0.11.dev0+git20190330-1) unstable; urgency=medium
+
+ * New upstream version
+ * Add dependency on python3-requests-toolbelt
+ * Bump-up Standards-Version
+ * Add NEWS.Debian to mention the change in config file format
+
+ -- Sebastien Delafond <seb@debian.org> Mon, 01 Apr 2019 10:12:50 +0200
+
mlbstreamer (0.0.10-3) unstable; urgency=medium
* Depend on python3-distutils (Closes: #905343)
diff --git a/debian/control b/debian/control
index 43e2b2a..54fbedf 100644
--- a/debian/control
+++ b/debian/control
@@ -3,14 +3,14 @@ Maintainer: Sebastien Delafond <seb@debian.org>
Section: video
Priority: optional
Build-Depends: dh-python, python3-setuptools, python3-all, python3-pytest, debhelper (>= 9)
-Standards-Version: 4.1.3
+Standards-Version: 4.3.0
Homepage: https://github.com/tonycpsu/mlbstreamer
Vcs-Git: https://salsa.debian.org/debian/mlbstreamer.git
Vcs-Browser: https://salsa.debian.org/debian/mlbstreamer
Package: mlbstreamer
Architecture: all
-Depends: ${misc:Depends}, streamlink (>= 0.11.0+dfsg-1), ${python3:Depends}, python3-panwid (>= 0.2.5-1), python3-urwid-utils (>= 0.1.2-1), python3-distutils
+Depends: ${misc:Depends}, streamlink (>= 0.11.0+dfsg-1), ${python3:Depends}, python3-panwid (>= 0.2.5-1), python3-urwid-utils (>= 0.1.2-1), python3-distutils, python3-requests-toolbelt
Description: Interface to the MLB.TV media offering
A collection of tools to stream and record baseball games from
MLB.TV. While the main streaming content is mostly for paid MLB.TV
diff --git a/docs/config.yaml.sample b/docs/config.yaml.sample
new file mode 100644
index 0000000..3245d7d
--- /dev/null
+++ b/docs/config.yaml.sample
@@ -0,0 +1,33 @@
+profiles:
+ default:
+ providers:
+ mlb: # MLB.tv
+ username: change@me.com
+ password: changeme
+ nhl: # NHL.tv
+ username: change@me2.com
+ password: changeme2
+
+ player: /usr/local/bin/mpv -no-border --osd-level=0
+ --force-seekable --hr-seek=yes --hr-seek-framedrop=yes
+ --keep-open=yes --keep-open-pause=no --no-window-dragging
+ --cache=2048 --cache-backbuffer=8192 --demuxer-seekable-cache=yes
+ streamlink_args: --hls-audio-select *
+ time_zone: America/New_York
+ default_resolution: 720p_alt
+ hide_spoiler_teams: false #true to hide all, or list, e.g.
+ # - PHI
+ # - PIT
+
+ 540p:
+ default_resolution: 540p
+ streamlink_args:
+ proxy:
+ proxies:
+ http: http://10.0.0.1:4123
+ https: http://10.0.0.1:4123
+
+#profile_map:
+ # use certain profiles for games involving certain teams,e.g.
+ # team:
+ # - pit: proxy
diff --git a/mlbstreamer/__init__.py b/mlbstreamer/__init__.py
index 9b36b86..746ff9c 100644
--- a/mlbstreamer/__init__.py
+++ b/mlbstreamer/__init__.py
@@ -1 +1 @@
-__version__ = "0.0.10"
+__version__ = "0.0.11.dev0"
diff --git a/mlbstreamer/__main__.py b/mlbstreamer/__main__.py
index 4fbbfb4..f384696 100644
--- a/mlbstreamer/__main__.py
+++ b/mlbstreamer/__main__.py
@@ -29,13 +29,14 @@ from .state import memo
from . import config
from . import play
from . import widgets
-from .util import *
-from .session import *
-
+from . import utils
+from . import session
+from .exceptions import *
class UrwidLoggingHandler(logging.Handler):
+ pipe = None
# def __init__(self, console):
# self.console = console
@@ -46,6 +47,8 @@ class UrwidLoggingHandler(logging.Handler):
def emit(self, rec):
+ if not self.pipe:
+ return
msg = self.format(rec)
(ignore, ready, ignore) = select.select([], [self.pipe], [])
if self.pipe in ready:
@@ -70,10 +73,10 @@ class Inning(AttrDict):
pass
-class LineScoreDataTable(DataTable):
+class MLBLineScoreDataTable(DataTable):
@classmethod
- def from_mlb_api(cls, line_score,
+ def from_json(cls, line_score,
away_team=None, home_team=None,
hide_spoilers=False
):
@@ -90,6 +93,7 @@ class LineScoreDataTable(DataTable):
data = []
for s, side in enumerate(["away", "home"]):
+ i = -1
line = AttrDict()
if isinstance(line_score["innings"], list):
@@ -108,9 +112,9 @@ class LineScoreDataTable(DataTable):
elif side in inning:
if isinstance(inning[side], dict) and "runs" in inning[side]:
setattr(line, str(i+1), parse_int(inning[side]["runs"]))
- else:
- if "runs" in inning[side]:
- inning_score.append(parse_int(inning[side]))
+ # else:
+ # if "runs" in inning[side]:
+ # inning_score.append(parse_int(inning[side]))
else:
setattr(line, str(i+1), "X")
@@ -141,36 +145,126 @@ class LineScoreDataTable(DataTable):
data.append(line)
- # raise Exception([c.name for c in columns])
return cls(columns, data=data)
- def keypress(self, size, key):
- key = super(LineScoreDataTable, self).keypress(size, key)
- if key == "l":
- logger.debug("enable")
- self.line_score_table.enable_cell_selection()
- return key
+ # def keypress(self, size, key):
+ # key = super(LineScoreDataTable, self).keypress(size, key)
+ # if key == "l":
+ # logger.debug("enable")
+ # self.line_score_table.enable_cell_selection()
+ # return key
+
+
+class NHLLineScoreDataTable(DataTable):
+
+ @classmethod
+ def from_json(cls, line_score,
+ away_team=None, home_team=None,
+ hide_spoilers=False
+ ):
+
+ columns = [
+ DataTableColumn("team", width=6, label="", align="right", padding=1),
+ ]
+
+ if "teams" in line_score:
+ tk = line_score["teams"]
+ else:
+ tk = line_score
+
+ data = []
+ for s, side in enumerate(["away", "home"]):
+
+ i = -1
+ line = AttrDict()
+ if "periods" in line_score and isinstance(line_score["periods"], list):
+ for i, period in enumerate(line_score["periods"]):
+ if not s:
+ columns.append(
+ DataTableColumn(str(i+1), label=str(i+1) if i < 3 else "O", width=3)
+ )
+ line.team = away_team
+ else:
+ line.team = home_team
+
+ if hide_spoilers:
+ setattr(line, str(i+1), "?")
+
+ elif side in period:
+ if isinstance(period[side], dict) and "goals" in period[side]:
+ setattr(line, str(i+1), parse_int(period[side]["goals"]))
+ else:
+ setattr(line, str(i+1), "X")
+
+ for n in list(range(i+1, 3)):
+ if not s:
+ columns.append(
+ DataTableColumn(str(n+1), label=str(n+1), width=3)
+ )
+ if hide_spoilers:
+ setattr(line, str(n+1), "?")
+
+ if not s:
+ columns.append(
+ DataTableColumn("empty", label="", width=3)
+ )
+
+ for stat in ["goals", "shotsOnGoal"]:
+ if not stat in tk[side]: continue
+
+ if not s:
+ columns.append(
+ DataTableColumn(stat, label=stat[0].upper(), width=3)
+ )
+ if not hide_spoilers:
+ setattr(line, stat, parse_int(tk[side][stat]))
+ else:
+ setattr(line, stat, "?")
+
+
+ data.append(line)
+ return cls(columns, data=data)
+
+
+
+def format_start_time(d):
+ s = datetime.strftime(d, "%I:%M%p").lower()[:-1]
+ if s[0] == "0":
+ s = s[1:]
+ return s
+class MediaAttributes(AttrDict):
+
+ def __repr__(self):
+ state = "!" if self.state == "MEDIA_ON" else "."
+ free = "_" if self.free else "$"
+ return f"{state}{free}"
+
class GamesDataTable(DataTable):
+ # sort_by = "start"
+
columns = [
- DataTableColumn("start", width=6, align="right"),
+ DataTableColumn("attrs", width=6, align="right"),
+ DataTableColumn("start", width=6, align="right",
+ format_fn = format_start_time),
# DataTableColumn("game_type", label="type", width=5, align="right"),
- DataTableColumn("away", width=13),
- DataTableColumn("home", width=13),
+ DataTableColumn("away", width=16),
+ DataTableColumn("home", width=16),
DataTableColumn("line"),
# DataTableColumn("game_id", width=6, align="right"),
]
- def __init__(self, sport_id, game_date, game_type=None, *args, **kwargs):
+ def __init__(self, provider, game_date, game_type=None, *args, **kwargs):
- self.sport_id = sport_id
+ # self.sport_id = sport_id
+
+ self.provider = provider
self.game_date = game_date
self.game_type = game_type
-
self.line_score_table = None
if not self.game_type:
self.game_type = ""
@@ -183,14 +277,16 @@ class GamesDataTable(DataTable):
def query(self, *args, **kwargs):
j = state.session.schedule(
- sport_id=self.sport_id,
+ # sport_id=self.sport_id,
start=self.game_date,
end=self.game_date,
game_type=self.game_type
)
for d in j["dates"]:
- for g in d["games"]:
+ games = sorted(d["games"], key= lambda g: g["gameDate"])
+
+ for g in games:
game_pk = g["gamePk"]
game_type = g["gameType"]
status = g["status"]["statusCode"]
@@ -199,14 +295,33 @@ class GamesDataTable(DataTable):
away_abbrev = g["teams"]["away"]["team"]["abbreviation"]
home_abbrev = g["teams"]["home"]["team"]["abbreviation"]
start_time = dateutil.parser.parse(g["gameDate"])
- if config.settings.time_zone:
- start_time = start_time.astimezone(config.settings.tz)
-
- hide_spoilers = set([away_abbrev, home_abbrev]).intersection(
- set(config.settings.get("hide_spoiler_teams", [])))
+ attrs = MediaAttributes()
+ try:
+ item = free_game = g["content"]["media"]["epg"][0]["items"][0]
+ attrs.state = item["mediaState"]
+ attrs.free = item["freeGame"]
+ except:
+ attrs.state = None
+ attrs.free = None
+
+ if config.settings.profile.time_zone:
+ start_time = start_time.astimezone(
+ pytz.timezone(config.settings.profile.time_zone)
+ )
- if "linescore" in g and len(g["linescore"]["innings"]):
- self.line_score_table = LineScoreDataTable.from_mlb_api(
+ hide_spoiler_teams = config.settings.profile.get("hide_spoiler_teams", [])
+ if isinstance(hide_spoiler_teams, bool):
+ hide_spoilers = hide_spoiler_teams
+ else:
+ hide_spoilers = set([away_abbrev, home_abbrev]).intersection(
+ set(hide_spoiler_teams))
+ # import json
+ # raise Exception(json.dumps(g["linescore"], sort_keys=True,
+ # indent=4, separators=(',', ': ')))
+ if "linescore" in g:
+ line_score_cls = globals().get(f"{self.provider.upper()}LineScoreDataTable")
+ # and "innings" in g["linescore"] and len(g["linescore"]["innings"]):
+ self.line_score_table = line_score_cls.from_json(
g["linescore"],
g["teams"]["away"]["team"]["abbreviation"],
g["teams"]["home"]["team"]["abbreviation"],
@@ -218,59 +333,77 @@ class GamesDataTable(DataTable):
)
else:
self.line_score = None
+
+ # timestr = datetime.strftime(
yield dict(
game_id = game_pk,
game_type = game_type,
away = away_team,
home = home_team,
- start = "%d:%02d%s" %(
- start_time.hour - 12 if start_time.hour > 12 else start_time.hour,
- start_time.minute,
- "p" if start_time.hour >= 12 else "a"
- ),
- line = self.line_score
+ start = start_time,
+ # start = "%d:%02d%s" %(
+ # start_time.hour - 12 if start_time.hour > 12 else start_time.hour,
+ # start_time.minute,
+ # "p" if start_time.hour >= 12 else "a"
+ # ),
+ line = self.line_score,
+ attrs = attrs
)
class ResolutionDropdown(Dropdown):
- items = [
- ("720p", "720p_alt"),
- ("720p@30", "720p"),
- ("540p", "540p"),
- ("504p", "504p"),
- ("360p", "360p"),
- ("288p", "288p"),
- ("224p", "224p")
- ]
-
label = "Resolution"
+ def __init__(self, resolutions, default=None):
+ self.resolutions = resolutions
+ super(ResolutionDropdown, self).__init__(resolutions, default=default)
+
+ @property
+ def items(self):
+ return self.resolutions
+
+
class Toolbar(urwid.WidgetWrap):
+ signals = ["provider_change"]
+
def __init__(self):
- self.league_dropdown = Dropdown(AttrDict([
- ("MLB", 1),
- ("AAA", 11),
- ]) , label="League")
+ # self.league_dropdown = Dropdown(AttrDict([
+ # ("MLB", 1),
+ # ("AAA", 11),
+ # ]) , label="League")
+
+
+ self.provider_dropdown = Dropdown(AttrDict(
+ [ (p.upper(), p)
+ for p in session.PROVIDERS]
+ ) , label="Provider", margin=1)
+
+ urwid.connect_signal(
+ self.provider_dropdown, "change",
+ lambda w, b, v: self._emit("provider_change", v)
+ )
self.live_stream_dropdown = Dropdown([
"live",
"from start"
], label="Live streams")
- self.resolution_dropdown = ResolutionDropdown(
- default=options.resolution
- )
+ self.resolution_dropdown_placeholder = urwid.WidgetPlaceholder(urwid.Text(""))
self.columns = urwid.Columns([
- ('weight', 1, self.league_dropdown),
+ ('weight', 1, self.provider_dropdown),
('weight', 1, self.live_stream_dropdown),
- ('weight', 1, self.resolution_dropdown),
+ ('weight', 1, self.resolution_dropdown_placeholder),
# ("weight", 1, urwid.Padding(urwid.Text("")))
])
self.filler = urwid.Filler(self.columns)
super(Toolbar, self).__init__(self.filler)
+ @property
+ def provider(self):
+ return (self.provider_dropdown.selected_value)
+
@property
def sport_id(self):
return (self.league_dropdown.selected_value)
@@ -284,6 +417,15 @@ class Toolbar(urwid.WidgetWrap):
return self.live_stream_dropdown.selected_label == "from start"
+ def set_resolutions(self, resolutions):
+
+ self.resolution_dropdown = ResolutionDropdown(
+ resolutions,
+ default=options.resolution
+ )
+ self.resolution_dropdown_placeholder.original_widget = self.resolution_dropdown
+
+
class DateBar(urwid.WidgetWrap):
def __init__(self, game_date):
@@ -325,12 +467,12 @@ class WatchDialog(BasePopUp):
self.game_id,
preferred_stream = "home"
))
+ self.live_stream = (home_feed.get("mediaState") == "MEDIA_ON")
self.feed_dropdown = Dropdown(
feed_map,
label="Feed",
default=home_feed["mediaId"]
)
-
urwid.connect_signal(
self.feed_dropdown,
"change",
@@ -383,7 +525,11 @@ class WatchDialog(BasePopUp):
timestamp_map["Live"] = False
self.inning_dropdown = Dropdown(
timestamp_map, label="Begin playback",
- default = timestamp_map["Start"] if self.from_beginning else timestamp_map["Live"]
+ default = (
+ timestamp_map["Start"] if (
+ not self.live_stream or self.from_beginning
+ ) else timestamp_map["Live"]
+ )
)
self.inning_dropdown_placeholder.original_widget = self.inning_dropdown
@@ -403,6 +549,12 @@ class WatchDialog(BasePopUp):
if key == "meta enter":
self.ok_button.keypress(size, "enter")
+ elif key in ["<", ">"]:
+ self.resolution_dropdown.cycle(1 if key == "<" else -1)
+ elif key in ["[", "]"]:
+ self.feed_dropdown.cycle(-1 if key == "[" else 1)
+ elif key in ["-", "="]:
+ self.inning_dropdown.cycle(-1 if key == "-" else 1)
else:
# return super(WatchDialog, self).keypress(size, key)
key = super(WatchDialog, self).keypress(size, key)
@@ -413,21 +565,43 @@ class WatchDialog(BasePopUp):
class ScheduleView(BaseView):
- def __init__(self, date):
+ def __init__(self, provider, date):
self.game_date = date
+
self.toolbar = Toolbar()
+ urwid.connect_signal(
+ self.toolbar, "provider_change",
+ lambda w, p: self.set_provider(p)
+ )
+
+ self.table_placeholder = urwid.WidgetPlaceholder(urwid.Text(""))
+
self.datebar = DateBar(self.game_date)
- self.table = GamesDataTable(self.toolbar.sport_id, self.game_date) # preseason
- urwid.connect_signal(self.table, "select",
- lambda source, selection: self.open_watch_dialog(selection["game_id"]))
+ # self.table = GamesDataTable(self.toolbar.sport_id, self.game_date) # preseason
self.pile = urwid.Pile([
(1, self.toolbar),
(1, self.datebar),
- ("weight", 1, self.table)
+ ("weight", 1, self.table_placeholder)
])
self.pile.focus_position = 2
+
super(ScheduleView, self).__init__(self.pile)
+ self.set_provider(provider)
+
+ def set_provider(self, provider):
+
+ logger.warning("set provider")
+ self.provider = provider
+ state.session = session.new(self.provider)
+ self.toolbar.set_resolutions(state.session.RESOLUTIONS)
+
+ self.table = GamesDataTable(self.provider, self.game_date) # preseason
+ self.table_placeholder.original_widget = self.table
+ urwid.connect_signal(self.table, "select",
+ lambda source, selection: self.open_watch_dialog(selection["game_id"]))
+
+
def open_watch_dialog(self, game_id):
dialog = WatchDialog(game_id,
@@ -448,17 +622,32 @@ class ScheduleView(BaseView):
self.game_date += timedelta(days= -1 if key == "left" else 1)
self.datebar.set_date(self.game_date)
self.table.set_game_date(self.game_date)
+ elif key in ["<", ">"]:
+ self.toolbar.resolution_dropdown.cycle(1 if key == "<" else -1)
+ elif key in ["-", "="]:
+ self.toolbar.live_stream_dropdown.cycle(1 if key == "-" else -1)
elif key == "t":
self.game_date = datetime.now().date()
self.datebar.set_date(self.game_date)
self.table.set_game_date(self.game_date)
elif key == "w": # watch home stream
- self.watch(self.table.selection.data.game_id, preferred_stream="home")
+ self.watch(
+ self.table.selection.data.game_id,
+ preferred_stream="home",
+ resolution=self.toolbar.resolution,
+ offset = 0 if self.toolbar.start_from_beginning else None
+ )
elif key == "W": # watch away stream
- self.watch(self.table.selection.data.game_id, preferred_stream="away")
+ self.watch(
+ self.table.selection.data.game_id,
+ preferred_stream="away",
+ resolution=self.toolbar.resolution,
+ offset = 0 if self.toolbar.start_from_beginning else None
+ )
else:
return key
+
def watch(self, game_id,
resolution=None, feed=None,
offset=None, preferred_stream=None):
@@ -472,7 +661,7 @@ class ScheduleView(BaseView):
offset = offset
)
except play.MLBPlayException as e:
- logger.error(e)
+ logger.warning(e)
@@ -483,39 +672,68 @@ def main():
today = datetime.now(pytz.timezone('US/Eastern')).date()
+ init_parser = argparse.ArgumentParser()
+ init_parser.add_argument("-p", "--profile", help="use alternate config profile")
+ options, args = init_parser.parse_known_args()
+
+ config.settings.load()
+
+ if options.profile:
+ config.settings.set_profile(options.profile)
+
parser = argparse.ArgumentParser()
- parser.add_argument("-d", "--date", help="game date",
- type=valid_date,
- default=today)
+ # parser.add_argument("-d", "--date", help="game date",
+ # type=utils.valid_date,
+ # default=today)
parser.add_argument("-r", "--resolution", help="stream resolution",
- default="720p_alt")
- parser.add_argument("-v", "--verbose", action="store_true")
+ default=config.settings.profile.default_resolution)
+ group = parser.add_mutually_exclusive_group()
+ group.add_argument("-v", "--verbose", action="count", default=0,
+ help="verbose logging")
+ group.add_argument("-q", "--quiet", action="count", default=0,
+ help="quiet logging")
+ parser.add_argument("game", metavar="game",
+ help="game specifier", nargs="?")
options, args = parser.parse_known_args()
log_file = os.path.join(config.CONFIG_DIR, "mlbstreamer.log")
- formatter = logging.Formatter(
- "%(asctime)s [%(module)16s:%(lineno)-4d] [%(levelname)8s] %(message)s",
- datefmt="%Y-%m-%d %H:%M:%S"
- )
+ # formatter = logging.Formatter(
+ # "%(asctime)s [%(module)16s:%(lineno)-4d] [%(levelname)8s] %(message)s",
+ # datefmt="%Y-%m-%d %H:%M:%S"
+ # )
fh = logging.FileHandler(log_file)
fh.setLevel(logging.DEBUG)
- fh.setFormatter(formatter)
+ # fh.setFormatter(formatter)
logger = logging.getLogger("mlbstreamer")
- logger.setLevel(logging.INFO)
- logger.addHandler(fh)
+ # logger.setLevel(logging.INFO)
+ # logger.addHandler(fh)
ulh = UrwidLoggingHandler()
- ulh.setLevel(logging.DEBUG)
- ulh.setFormatter(formatter)
- logger.addHandler(ulh)
+ # ulh.setLevel(logging.DEBUG)
+ # ulh.setFormatter(formatter)
+ # logger.addHandler(ulh)
- logger.debug("mlbstreamer starting")
- config.settings.load()
+ utils.setup_logging(options.verbose - options.quiet,
+ handlers=[fh, ulh],
+ quiet_stdout=True)
+
+ try:
+ (provider, game_date) = options.game.split("/", 1)
+ except (ValueError, AttributeError):
+ if options.game in session.PROVIDERS:
+ provider = options.game
+ game_date = datetime.now().date()
+ else:
+ provider = list(config.settings.profile.providers.keys())[0]
+ game_date = dateutil.parser.parse(options.game)
- state.session = MLBSession.new()
+
+
+
+ logger.debug("mlbstreamer starting")
entries = Dropdown.get_palette_entries()
entries.update(ScrollingListBox.get_palette_entries())
@@ -525,13 +743,13 @@ def main():
screen = urwid.raw_display.Screen()
screen.set_terminal_properties(256)
- view = ScheduleView(options.date)
+ view = ScheduleView(provider, game_date)
log_console = widgets.ConsoleWindow()
# log_box = urwid.BoxAdapter(urwid.LineBox(log_console), 10)
pile = urwid.Pile([
- ("weight", 1, urwid.LineBox(view)),
- (6, urwid.LineBox(log_console))
+ ("weight", 5, urwid.LineBox(view)),
+ ("weight", 1, urwid.LineBox(log_console))
])
def global_input(key):
diff --git a/mlbstreamer/config.py b/mlbstreamer/config.py
index 5d3099e..ae74241 100644
--- a/mlbstreamer/config.py
+++ b/mlbstreamer/config.py
@@ -7,7 +7,8 @@ try:
except ImportError:
from collections import MutableMapping
import yaml
-from orderedattrdict import AttrDict
+import functools
+from orderedattrdict import Tree
import orderedattrdict.yamlutils
from orderedattrdict.yamlutils import AttrDictYAMLLoader
import distutils.spawn
@@ -67,17 +68,70 @@ class RangeNumberValidator(Validator):
message="Value must be less than %s" %(self.maximum)
)
+class ProfileTree(Tree):
-class Config(MutableMapping):
+ DEFAULT_PROFILE_NAME = "default"
- def __init__(self, config_file):
+ def __init__(self, profile=DEFAULT_PROFILE_NAME, *args, **kwargs):
+ super(ProfileTree, self).__init__(*args, **kwargs)
+ self.__exclude_keys__ |= {"_profile_name", "_default_profile_name", "profile"}
+ self._default_profile_name = profile
+ self.set_profile(self._default_profile_name)
- self._config = None
+ @property
+ def profile(self):
+ return self[self._profile_name]
+
+ def set_profile(self, profile):
+ self._profile_name = profile
+
+ def __getattr__(self, name):
+ if not name.startswith("_"):
+ p = self.profile
+ return p.get(name) if name in p else self[self._default_profile_name].get(name)
+ raise AttributeError
+
+ def __setattr__(self, name, value):
+ if not name.startswith("_"):
+ self[self._profile_name][name] = value
+ else:
+ object.__setattr__(self, name, value)
+
+ def get(self, name, default=None):
+ p = self.profile
+ return p.get(name, default) if name in p else self[self._default_profile_name].get(name, default)
+
+ def __getitem__(self, name):
+ if isinstance(name, tuple):
+ return functools.reduce(
+ lambda a, b: AttrDict(a, **{ k: v for k, v in b.items() if k not in a}),
+ [ self[p] for p in reversed(name) ]
+ )
+
+ else:
+ return super(ProfileTree, self).__getitem__(name)
+
+class Config(Tree):
+
+ DEFAULT_PROFILE = "default"
+
+ def __init__(self, config_file, *args, **kwargs):
+ super(Config, self).__init__(*args, **kwargs)
+ self.__exclude_keys__ |= {"_config_file", "set_profile", "_profile_tree"}
self._config_file = config_file
+ self.load()
+ self._profile_tree = ProfileTree(**self.profiles)
+
def init_config(self):
- from .session import MLBSession, MLBSessionException
+ raise Exception("""
+ Sorry, this configurator needs to be updated to reflect recent changes
+ to the config file. Until this is fixed, use the sample config found
+ in the "docs" directory of the distribution.
+ """)
+
+ from .session import StreamSession, StreamSessionException
def mkdir_p(path):
try:
@@ -92,27 +146,27 @@ class Config(MutableMapping):
if player:
yield player
- MLBSession.destroy()
+ StreamSession.destroy()
if os.path.exists(CONFIG_FILE):
os.remove(CONFIG_FILE)
- self._config = AttrDict()
time_zone = None
player = None
mkdir_p(CONFIG_DIR)
while True:
- self.username = prompt(
- "MLB.tv username: ",
+ self.profile.username = prompt(
+ "MLB.com username: ",
validator=NotEmptyValidator())
- self.password = prompt(
+ self.profile.password = prompt(
'Enter password: ',
is_password=True, validator=NotEmptyValidator())
try:
- s = MLBSession(self.username, self.password)
+ s = StreamSession(self.profile.username,
+ self.profile.password)
s.login()
break
- except MLBSessionException:
+ except StreamSessionException:
print("Couldn't login to MLB, please check your credentials.")
continue
@@ -149,7 +203,22 @@ class Config(MutableMapping):
if player_args:
player = " ".join([player, player_args])
- self.player = player
+ self.profile.player = player
+
+ print("\n".join(
+ [ "\t%d: %s" %(n, l)
+ for n, l in enumerate(
+ utils.MLB_HLS_RESOLUTION_MAP
+ )]))
+ print("Select a default video resolution for MLB.tv streams:")
+ choice = int(
+ prompt(
+ "Choice: ",
+ validator=RangeNumberValidator(maximum=len(utils.MLB_HLS_RESOLUTION_MAP))))
+ if choice is not None:
+ self.profile.default_resolution = utils.MLB_HLS_RESOLUTION_MAP[
+ list(utils.MLB_HLS_RESOLUTION_MAP.keys())[choice]
+ ]
print("Your system time zone seems to be %s." %(tz_local))
if not confirm("Is that the time zone you'd like to use? (y/n) "):
@@ -165,46 +234,31 @@ class Config(MutableMapping):
else:
time_zone = tz_local
- self.time_zone = time_zone
+ self.profile.time_zone = time_zone
self.save()
- def load(self):
- if not os.path.exists(self._config_file):
- raise Exception("config file %s not found" %(CONFIG_FILE))
+ @property
+ def profile(self):
+ return self._profile_tree
- config = yaml.load(open(self._config_file), Loader=AttrDictYAMLLoader)
- if config.get("time_zone"):
- config.tz = pytz.timezone(config.time_zone)
- self._config = config
+ @property
+ def profiles(self):
+ return self._profile_tree
- def save(self):
-
- with open(self._config_file, 'w') as outfile:
- yaml.dump(self._config, outfile, default_flow_style=False)
-
- def __getattr__(self, name):
- return self._config.get(name, None)
-
- def __setattr__(self, name, value):
+ def set_profile(self, profile):
+ self._profile_tree.set_profile(profile)
- if not name.startswith("_"):
- self._config[name] = value
- object.__setattr__(self, name, value)
-
- def __getitem__(self, key):
- return self._config[key]
-
- def __setitem__(self, key, value):
- self._config[key] = value
-
- def __delitem__(self, key):
- del self._config[key]
+ def load(self):
+ if os.path.exists(self._config_file):
+ config = yaml.load(open(self._config_file), Loader=AttrDictYAMLLoader)
+ self.update(config.items())
- def __len__(self):
- return len(self._config)
+ def save(self):
- def __iter__(self):
- return iter(self._config)
+ d = Tree([ (k, v) for k, v in self.items()])
+ d.update({"profiles": self._profile_tree})
+ with open(self._config_file, 'w') as outfile:
+ yaml.dump(d, outfile, default_flow_style=False, indent=4)
settings = Config(CONFIG_FILE)
@@ -214,3 +268,18 @@ __all__ = [
"config",
"settings"
]
+
+def main():
+ settings.set_profile("default")
+ print(settings.profile.default_resolution)
+ settings.set_profile("540p")
+ print(settings.profile.default_resolution)
+ print(settings.profile.get("env"))
+ print(settings.profiles["default"])
+ print(settings.profiles[("default")].get("env"))
+ print(settings.profiles[("default", "540p")].get("env"))
+ print(settings.profiles[("default", "540p")].get("env"))
+ print(settings.profiles[("default", "540p", "proxy")].get("env"))
+
+if __name__ == "__main__":
+ main()
diff --git a/mlbstreamer/exceptions.py b/mlbstreamer/exceptions.py
new file mode 100644
index 0000000..fb13111
--- /dev/null
+++ b/mlbstreamer/exceptions.py
@@ -0,0 +1,8 @@
+class MLBPlayException(Exception):
+ pass
+
+class MLBPlayInvalidArgumentError(MLBPlayException):
+ pass
+
+class StreamSessionException(MLBPlayException):
+ pass
diff --git a/mlbstreamer/play.py b/mlbstreamer/play.py
index 029dbdb..8fe83c1 100755
--- a/mlbstreamer/play.py
+++ b/mlbstreamer/play.py
@@ -9,35 +9,47 @@ import argparse
from datetime import datetime, timedelta
import pytz
import shlex
+from itertools import chain
import dateutil.parser
from orderedattrdict import AttrDict
from . import config
from . import state
-from .util import *
-from .session import *
+from . import session
+from . import utils
+from .exceptions import *
+# from .session import *
-class MLBPlayException(Exception):
- pass
-class MLBPlayInvalidArgumentError(MLBPlayException):
- pass
+def handle_exception(exc_type, exc_value, exc_traceback):
+ if state.session:
+ state.session.save()
+ if issubclass(exc_type, KeyboardInterrupt):
+ sys.__excepthook__(exc_type, exc_value, exc_traceback)
+ return
+
+ logger.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback))
+
+sys.excepthook = handle_exception
def play_stream(game_specifier, resolution=None,
offset=None,
media_id = None,
preferred_stream=None,
call_letters=None,
- output=None):
+ output=None,
+ verbose=0):
live = False
team = None
game_number = 1
- sport_code = "mlb" # default sport is MLB
+ game_date = None
+ # sport_code = "mlb" # default sport is MLB
- media_title = "MLBTV"
+ # media_title = "MLBTV"
media_id = None
+ allow_stdout=False
if resolution is None:
resolution = "best"
@@ -50,43 +62,23 @@ def play_stream(game_specifier, resolution=None,
else:
try:
- (game_date, team, game_number) = game_specifier
+ (game_date, team, game_number) = game_specifier.split(".")
except ValueError:
- (game_date, team) = game_specifier
-
- if "/" in team:
- (sport_code, team) = team.split("/")
-
-
- if sport_code != "mlb":
- media_title = "MiLBTV"
- raise MLBPlayException("Sorry, MiLB.tv streams are not yet supported")
-
- sports_url = (
- "http://statsapi.mlb.com/api/v1/sports"
- )
- with state.session.cache_responses_long():
- sports = state.session.get(sports_url).json()
-
- sport = next(s for s in sports["sports"] if s["code"] == sport_code)
+ try:
+ (game_date, team) = game_specifier.split(".")
+ except ValueError:
+ game_date = datetime.now().date()
+ team = game_specifier
- season = game_date.year
- teams_url = (
- "http://statsapi.mlb.com/api/v1/teams"
- "?sportId={sport}&season={season}".format(
- sport=sport["id"],
- season=season
- )
- )
+ if "-" in team:
+ (sport_code, team) = team.split("-")
- with state.session.cache_responses_long():
- teams = AttrDict(
- (team["abbreviation"].lower(), team["id"])
- for team in sorted(state.session.get(teams_url).json()["teams"],
- key=lambda t: t["fileCode"])
- )
+ game_date = dateutil.parser.parse(game_date)
+ game_number = int(game_number)
+ teams = state.session.teams(season=game_date.year)
+ team_id = teams.get(team)
- if team not in teams:
+ if not team:
msg = "'%s' not a valid team code, must be one of:\n%s" %(
game_specifier, " ".join(teams)
)
@@ -95,9 +87,10 @@ def play_stream(game_specifier, resolution=None,
schedule = state.session.schedule(
start = game_date,
end = game_date,
- sport_id = sport["id"],
- team_id = teams[team]
+ # sport_id = sport["id"],
+ team_id = team_id
)
+ # raise Exception(schedule)
try:
@@ -113,10 +106,13 @@ def play_stream(game_specifier, resolution=None,
game_id, resolution)
)
+ away_team_abbrev = game["teams"]["away"]["team"]["abbreviation"].lower()
+ home_team_abbrev = game["teams"]["home"]["team"]["abbreviation"].lower()
+
if not preferred_stream or call_letters:
preferred_stream = (
"away"
- if team == game["teams"]["away"]["team"]["abbreviation"].lower()
+ if team == away_team_abbrev
else "home"
)
@@ -124,26 +120,42 @@ def play_stream(game_specifier, resolution=None,
media = next(state.session.get_media(
game_id,
media_id = media_id,
- title=media_title,
+ # title=media_title,
preferred_stream=preferred_stream,
call_letters = call_letters
))
except StopIteration:
raise MLBPlayException("no matching media for game %d" %(game_id))
- media_id = media["mediaId"] if "mediaId" in media else media["guid"]
+ # media_id = media["mediaId"] if "mediaId" in media else media["guid"]
media_state = media["mediaState"]
+ # Get any team-specific profile overrides, and apply settings for them
+ profiles = tuple([ list(d.values())[0]
+ for d in config.settings.profile_map.get("team", {})
+ if list(d.keys())[0] in [
+ away_team_abbrev, home_team_abbrev
+ ] ])
+
+ if len(profiles):
+ # override proxies for team, if defined
+ if len(config.settings.profiles[profiles].proxies):
+ old_proxies = state.session.proxies
+ state.session.proxies = config.settings.profiles[profiles].proxies
+ state.session.refresh_access_token(clear_token=True)
+ state.session.proxies = old_proxies
+
if "playbacks" in media:
playback = media["playbacks"][0]
media_url = playback["location"]
else:
- stream = state.session.get_stream(media_id)
+ stream = state.session.get_stream(media)
try:
- media_url = stream["stream"]["complete"]
- except TypeError:
+ # media_url = stream["stream"]["complete"]
+ media_url = stream.url
+ except (TypeError, AttributeError):
raise MLBPlayException("no stream URL for game %d" %(game_id))
offset_timestamp = None
@@ -177,21 +189,48 @@ def play_stream(game_specifier, resolution=None,
offset_timestamp = str(offset_delta)
logger.info("starting at time offset %s" %(offset))
+ header_args = []
+ cookie_args = []
+
+ if state.session.headers:
+ header_args = list(
+ chain.from_iterable([
+ ("--http-header", f"{k}={v}")
+ for k, v in state.session.headers.items()
+ ]))
+
+ if state.session.cookies:
+ cookie_args = list(
+ chain.from_iterable([
+ ("--http-cookie", f"{c.name}={c.value}")
+ for c in state.session.cookies
+ ]))
+
cmd = [
"streamlink",
# "-l", "debug",
- "--player", config.settings.player,
- "--http-header",
- "Authorization=%s" %(state.session.access_token),
+ "--player", config.settings.profile.player,
+ ] + cookie_args + header_args + [
media_url,
resolution,
]
- if config.settings.streamlink_args:
- cmd += shlex.split(config.settings.streamlink_args)
+
+ if config.settings.profile.streamlink_args:
+ cmd += shlex.split(config.settings.profile.streamlink_args)
if offset_timestamp:
cmd += ["--hls-start-offset", offset_timestamp]
+ if verbose > 1:
+
+ allow_stdout=True
+ cmd += ["-l", "debug"]
+
+ if verbose > 2:
+ if not output:
+ cmd += ["-v"]
+ cmd += ["--ffmpeg-verbose"]
+
if output is not None:
if output == True or os.path.isdir(output):
outfile = get_output_filename(
@@ -208,7 +247,7 @@ def play_stream(game_specifier, resolution=None,
cmd += ["-o", outfile]
logger.debug("Running cmd: %s" % " ".join(cmd))
- proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
+ proc = subprocess.Popen(cmd, stdout=None if allow_stdout else open(os.devnull, 'w'))
return proc
@@ -270,64 +309,67 @@ def main():
today = datetime.now(pytz.timezone('US/Eastern')).date()
- parser = argparse.ArgumentParser()
- parser.add_argument("-d", "--date", help="game date",
- type=valid_date,
- default=today)
- parser.add_argument("-g", "--game-number",
- help="number of team game on date (for doubleheaders)",
- default=1,
- type=int)
+ init_parser = argparse.ArgumentParser(add_help=False)
+ init_parser.add_argument("--init-config", help="initialize configuration",
+ action="store_true")
+ init_parser.add_argument("-p", "--profile", help="use alternate config profile")
+ options, args = init_parser.parse_known_args()
+
+ if options.init_config:
+ config.settings.init_config()
+ sys.exit(0)
+
+ config.settings.load()
+
+ if options.profile:
+ config.settings.set_profile(options.profile)
+
+ parser = argparse.ArgumentParser(
+ description=init_parser.format_help(),
+ formatter_class=argparse.RawDescriptionHelpFormatter
+ )
+
parser.add_argument("-b", "--begin",
help="begin playback at this offset from start",
nargs="?", metavar="offset_from_game_start",
type=begin_arg_to_offset,
const=0)
parser.add_argument("-r", "--resolution", help="stream resolution",
- default="720p")
+ default=config.settings.profile.default_resolution)
parser.add_argument("-s", "--save-stream", help="save stream to file",
nargs="?", const=True)
parser.add_argument("--no-cache", help="do not use response cache",
action="store_true")
- parser.add_argument("-v", "--verbose", action="store_true",
+ group = parser.add_mutually_exclusive_group()
+ group.add_argument("-v", "--verbose", action="count", default=0,
help="verbose logging")
- parser.add_argument("--init-config", help="initialize configuration",
- action="store_true")
+ group.add_argument("-q", "--quiet", action="count", default=0,
+ help="quiet logging")
parser.add_argument("game", metavar="game",
nargs="?",
help="team abbreviation or MLB game ID")
- options, args = parser.parse_known_args()
-
- global logger
- logger = logging.getLogger("mlbstreamer")
- if options.verbose:
- logger.setLevel(logging.DEBUG)
- formatter = logging.Formatter("%(asctime)s [%(levelname)8s] %(message)s",
- datefmt='%Y-%m-%d %H:%M:%S')
- handler = logging.StreamHandler(sys.stdout)
- handler.setFormatter(formatter)
- logger.addHandler(handler)
+ options, args = parser.parse_known_args(args)
+
+ try:
+ (provider, game) = options.game.split("/", 1)
+ except ValueError:
+ game = options.game#.split(".", 1)[1]
+ provider = list(config.settings.profile.providers.keys())[0]
+
+ if game.isdigit():
+ game_specifier = int(game)
else:
- logger.addHandler(logging.NullHandler())
+ game_specifier = game
- if options.init_config:
- config.settings.init_config()
- sys.exit(0)
- config.settings.load()
+ utils.setup_logging(options.verbose - options.quiet)
if not options.game:
parser.error("option game")
- state.session = MLBSession.new(no_cache=options.no_cache)
-
+ state.session = session.new(provider)
preferred_stream = None
date = None
- if options.game.isdigit():
- game_specifier = int(options.game)
- else:
- game_specifier = (options.date, options.game, options.game_number)
-
try:
proc = play_stream(
game_specifier,
@@ -335,6 +377,7 @@ def main():
offset = options.begin,
preferred_stream = preferred_stream,
output = options.save_stream,
+ verbose = options.verbose
)
proc.wait()
except MLBPlayInvalidArgumentError as e:
diff --git a/mlbstreamer/session.py b/mlbstreamer/session.py
index 88f5d63..df42b02 100644
--- a/mlbstreamer/session.py
+++ b/mlbstreamer/session.py
@@ -8,13 +8,15 @@ import json
import sqlite3
import pickle
import functools
+import random
+import string
from contextlib import contextmanager
import six
-from six.moves.http_cookiejar import LWPCookieJar
+from six.moves.http_cookiejar import LWPCookieJar, Cookie
from six import StringIO
import requests
-# from requests_toolbelt.utils import dump
+from requests_toolbelt.utils import dump
import lxml
import lxml, lxml.etree
import yaml
@@ -28,57 +30,44 @@ import dateutil.parser
from . import config
from . import state
from .state import memo
+from .exceptions import *
USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:56.0) "
"Gecko/20100101 Firefox/56.0.4")
-PLATFORM = "macintosh"
-BAM_SDK_VERSION="3.0"
-
-API_KEY_URL= "https://www.mlb.com/tv/g490865/"
-API_KEY_RE = re.compile(r'"apiKey":"([^"]+)"')
-CLIENT_API_KEY_RE = re.compile(r'"clientApiKey":"([^"]+)"')
-
-TOKEN_URL_TEMPLATE = (
- "https://media-entitlement.mlb.com/jwt"
- "?ipid={ipid}&fingerprint={fingerprint}==&os={platform}&appname=mlbtv_web"
-)
+# Default cache duration to 60 seconds
+CACHE_DURATION_SHORT = 60 # 60 seconds
+CACHE_DURATION_MEDIUM = 60*60*24 # 1 day
+CACHE_DURATION_LONG = 60*60*24*30 # 30 days
+CACHE_DURATION_DEFAULT = CACHE_DURATION_SHORT
-GAME_CONTENT_URL_TEMPLATE="http://statsapi.mlb.com/api/v1/game/{game_id}/content"
+CACHE_FILE=os.path.join(config.CONFIG_DIR, "cache.sqlite")
-# GAME_FEED_URL = "http://statsapi.mlb.com/api/v1/game/{game_id}/feed/live"
+def gen_random_string(n):
+ return ''.join(
+ random.choice(
+ string.ascii_uppercase + string.digits
+ ) for _ in range(64)
+ )
-SCHEDULE_TEMPLATE=(
- "http://statsapi.mlb.com/api/v1/schedule"
- "?sportId={sport_id}&startDate={start}&endDate={end}"
- "&gameType={game_type}&gamePk={game_id}"
- "&teamId={team_id}"
- "&hydrate=linescore,team,game(content(summary,media(epg)),tickets)"
-)
-ACCESS_TOKEN_URL = "https://edge.bamgrid.com/token"
+class Media(AttrDict):
+ pass
-STREAM_URL_TEMPLATE="https://edge.svcs.mlb.com/media/{media_id}/scenarios/browser"
-AIRINGS_URL_TEMPLATE=(
- "https://search-api-mlbtv.mlb.com/svc/search/v2/graphql/persisted/query/"
- "core/Airings?variables={{%22partnerProgramIds%22%3A[%22{game_id}%22]}}"
-)
+class Stream(AttrDict):
+ pass
-SESSION_FILE=os.path.join(config.CONFIG_DIR, "session")
-COOKIE_FILE=os.path.join(config.CONFIG_DIR, "cookies")
-CACHE_FILE=os.path.join(config.CONFIG_DIR, "cache.sqlite")
+class StreamSession(object):
+ """
+ Top-level stream session interface
-# Default cache duration to 60 seconds
-CACHE_DURATION_SHORT = 60 # 60 seconds
-CACHE_DURATION_MEDIUM = 60*60*24 # 1 day
-CACHE_DURATION_LONG = 60*60*24*30 # 30 days
-CACHE_DURATION_DEFAULT = CACHE_DURATION_SHORT
+ Individual stream providers can be implemented by inheriting from this class
+ and implementing methods for login flow, getting streams, etc.
+ """
-class MLBSessionException(Exception):
- pass
-class MLBSession(object):
+ # SESSION_FILE=os.path.join(config.CONFIG_DIR, "session")
HEADERS = {
"User-agent": USER_AGENT
@@ -87,28 +76,21 @@ class MLBSession(object):
def __init__(
self,
username, password,
- api_key=None,
- client_api_key=None,
- token=None,
- access_token=None,
- access_token_expiry=None,
- no_cache=False
+ proxies=None,
+ no_cache=False,
+ *args, **kwargs
):
self.session = requests.Session()
- self.session.cookies = LWPCookieJar()
- if not os.path.exists(COOKIE_FILE):
- self.session.cookies.save(COOKIE_FILE)
- self.session.cookies.load(COOKIE_FILE, ignore_discard=True)
+ self.cookies = LWPCookieJar()
+ if not os.path.exists(self.COOKIES_FILE):
+ self.cookies.save(self.COOKIES_FILE)
+ self.cookies.load(self.COOKIES_FILE, ignore_discard=True)
self.session.headers = self.HEADERS
self._state = AttrDict([
("username", username),
("password", password),
- ("api_key", api_key),
- ("client_api_key", client_api_key),
- ("token", token),
- ("access_token", access_token),
- ("access_token_expiry", access_token_expiry)
+ ("proxies", proxies)
])
self.no_cache = no_cache
self._cache_responses = False
@@ -118,21 +100,88 @@ class MLBSession(object):
detect_types = sqlite3.PARSE_DECLTYPES)
self.cursor = self.conn.cursor()
self.cache_purge()
+ # if not self.logged_in:
self.login()
+ # logger.debug("already logged in")
+ # return
+
+
+
+ @classmethod
+ def session_type(cls):
+ return cls.__name__.replace("StreamSession", "").lower()
+
+ @classmethod
+ def _COOKIES_FILE(cls):
+ return os.path.join(config.CONFIG_DIR, f"{cls.session_type()}.cookies")
+
+ @property
+ def COOKIES_FILE(self):
+ return self._COOKIES_FILE()
+
+ @classmethod
+ def _SESSION_FILE(cls):
+ return os.path.join(config.CONFIG_DIR, f"{cls.session_type()}.session")
+
+ @property
+ def SESSION_FILE(self):
+ return self._SESSION_FILE()
+
+ @classmethod
+ def new(cls, **kwargs):
+ try:
+ return cls.load(**kwargs)
+ except FileNotFoundError:
+ logger.trace(f"creating new session: {kwargs}")
+ provider = config.settings.profile.providers.get(cls.session_type())
+ return cls(username=provider.username,
+ password=provider.password,
+ **kwargs)
+
+ @property
+ def cookies(self):
+ return self.session.cookies
+
+ @cookies.setter
+ def cookies(self, value):
+ self.session.cookies = value
+
+ @classmethod
+ def destroy(cls):
+ if os.path.exists(cls.COOKIES_FILE):
+ os.remove(cls.COOKIES_FILE)
+ if os.path.exists(cls.SESSION_FILE):
+ os.remove(cls.SESSION_FILE)
+
+ @classmethod
+ def load(cls, *args, **kwargs):
+ state = yaml.load(open(cls._SESSION_FILE()), Loader=AttrDictYAMLLoader)
+ logger.trace(f"load: {cls.__name__}, {state}")
+ return cls(**state)
+
+ def save(self):
+ logger.trace(f"load: {self.__class__.__name__}, {self._state}")
+ with open(self.SESSION_FILE, 'w') as outfile:
+ yaml.dump(self._state, outfile, default_flow_style=False)
+ self.cookies.save(self.COOKIES_FILE)
+
+
+ def get_cookie(self, name):
+ return requests.utils.dict_from_cookiejar(self.cookies).get(name)
def __getattr__(self, attr):
if attr in ["delete", "get", "head", "options", "post", "put", "patch"]:
# return getattr(self.session, attr)
session_method = getattr(self.session, attr)
return functools.partial(self.request, session_method)
- # raise AttributeError(attr)
+ raise AttributeError(attr)
def request(self, method, url, *args, **kwargs):
response = None
use_cache = not self.no_cache and self._cache_responses
if use_cache:
- logger.debug("getting cached response for %s" %(url))
+ logger.debug("getting cached response fsesor %s" %(url))
self.cursor.execute(
"SELECT response, last_seen "
"FROM response_cache "
@@ -150,9 +199,9 @@ class MLBSession(object):
except TypeError:
logger.debug("no cached response for %s" %(url))
- if not response:
- response = method(url, *args, **kwargs)
-
+ # if not response:
+ # response = method(url, *args, **kwargs)
+ # logger.trace(dump.dump_all(response).decode("utf-8"))
if use_cache:
pickled_response = pickle.dumps(response)
sql="""INSERT OR REPLACE
@@ -174,31 +223,22 @@ class MLBSession(object):
def password(self):
return self._state.password
- @classmethod
- def new(cls, **kwargs):
- try:
- return cls.load()
- except:
- return cls(username=config.settings.username,
- password=config.settings.password,
- **kwargs)
+ @property
+ def proxies(self):
+ return self._state.proxies
- @classmethod
- def destroy(cls):
- if os.path.exists(COOKIE_FILE):
- os.remove(COOKIE_FILE)
- if os.path.exists(SESSION_FILE):
- os.remove(SESSION_FILE)
+ @property
+ def headers(self):
+ return []
- @classmethod
- def load(cls):
- state = yaml.load(open(SESSION_FILE), Loader=AttrDictYAMLLoader)
- return cls(**state)
+ @proxies.setter
+ def proxies(self, value):
+ # Override proxy environment variables if proxies are defined on session
+ if value is not None:
+ self.session.trust_env = (len(value) == 0)
+ self._state.proxies = value
+ self.session.proxies.update(value)
- def save(self):
- with open(SESSION_FILE, 'w') as outfile:
- yaml.dump(self._state, outfile, default_flow_style=False)
- self.session.cookies.save(COOKIE_FILE)
@contextmanager
def cache_responses(self, duration=CACHE_DURATION_DEFAULT):
@@ -238,58 +278,196 @@ class MLBSession(object):
"WHERE last_seen < datetime('now', '-%d days')" %(days)
)
- def login(self):
+class BAMStreamSessionMixin(object):
+ """
+ StreamSession subclass for BAMTech Media stream providers, which currently
+ includes MLB.tv and NHL.tv
+ """
+ sport_id = 1 # FIXME
- logger.debug("checking for existing log in")
+ @memo(region="short")
+ def schedule(
+ self,
+ # sport_id=None,
+ start=None,
+ end=None,
+ game_type=None,
+ team_id=None,
+ game_id=None,
+ ):
- initial_url = ("https://secure.mlb.com/enterworkflow.do"
- "?flowId=registration.wizard&c_id=mlb")
+ logger.debug(
+ "getting schedule: %s, %s, %s, %s, %s, %s" %(
+ self.sport_id,
+ start,
+ end,
+ game_type,
+ team_id,
+ game_id
+ )
+ )
+ url = self.SCHEDULE_TEMPLATE.format(
+ sport_id = self.sport_id,
+ start = start.strftime("%Y-%m-%d") if start else "",
+ end = end.strftime("%Y-%m-%d") if end else "",
+ game_type = game_type if game_type else "",
+ team_id = team_id if team_id else "",
+ game_id = game_id if game_id else ""
+ )
+ with self.cache_responses_short():
+ return self.session.get(url).json()
- # res = self.get(initial_url)
- # if not res.status_code == 200:
- # raise MLBSessionException(res.content)
+ @memo(region="short")
+ def get_epgs(self, game_id, title=None):
- data = {
- "uri": "/account/login_register.jsp",
- "registrationAction": "identify",
- "emailAddress": self.username,
- "password": self.password,
- "submitButton": ""
- }
- if self.logged_in:
- logger.debug("already logged in")
+ schedule = self.schedule(game_id=game_id)
+ try:
+ # Get last date for games that have been rescheduled to a later date
+ game = schedule["dates"][-1]["games"][0]
+ except KeyError:
+ logger.debug("no game data")
return
+ epgs = game["content"]["media"]["epg"]
+
+ if not isinstance(epgs, list):
+ epgs = [epgs]
- logger.debug("attempting new log in")
+ return [ e for e in epgs if (not title) or title == e["title"] ]
+
+ def get_media(self,
+ game_id,
+ media_id=None,
+ title=None,
+ preferred_stream=None,
+ call_letters=None):
- login_url = "https://securea.mlb.com/authenticate.do"
+ logger.debug(f"geting media for game {game_id} ({media_id}, {title}, {call_letters})")
- res = self.post(
- login_url,
- data=data,
- headers={"Referer": (initial_url)}
+ epgs = self.get_epgs(game_id, title)
+ for epg in epgs:
+ for item in epg["items"]:
+ if (not preferred_stream
+ or (item.get("mediaFeedType", "").lower() == preferred_stream)
+ ) and (
+ not call_letters
+ or (item.get("callLetters", "").lower() == call_letters)
+ ) and (
+ not media_id
+ or (item.get("mediaId", "").lower() == media_id)
+ ):
+ logger.debug("found preferred stream")
+ yield Media(item)
+ else:
+ if len(epg["items"]):
+ logger.debug("using non-preferred stream")
+ yield Media(epg["items"][0])
+ # raise StopIteration
+
+
+
+class MLBStreamSession(BAMStreamSessionMixin, StreamSession):
+
+ SCHEDULE_TEMPLATE = (
+ "http://statsapi.mlb.com/api/v1/schedule"
+ "?sportId={sport_id}&startDate={start}&endDate={end}"
+ "&gameType={game_type}&gamePk={game_id}"
+ "&teamId={team_id}"
+ "&hydrate=linescore,team,game(content(summary,media(epg)),tickets)"
+ )
+
+ PLATFORM = "macintosh"
+
+ BAM_SDK_VERSION = "3.4"
+
+ MLB_API_KEY_URL = "https://www.mlb.com/tv/g490865/"
+
+ API_KEY_RE = re.compile(r'"apiKey":"([^"]+)"')
+
+ CLIENT_API_KEY_RE = re.compile(r'"clientApiKey":"([^"]+)"')
+
+ OKTA_CLIENT_ID_RE = re.compile("""production:{clientId:"([^"]+)",""")
+
+ MLB_OKTA_URL = "https://www.mlbstatic.com/mlb.com/vendor/mlb-okta/mlb-okta.js"
+
+ AUTHN_URL = "https://ids.mlb.com/api/v1/authn"
+
+ AUTHZ_URL = "https://ids.mlb.com/oauth2/aus1m088yK07noBfh356/v1/authorize"
+
+ BAM_DEVICES_URL = "https://us.edge.bamgrid.com/devices"
+
+ BAM_SESSION_URL = "https://us.edge.bamgrid.com/session"
+
+ BAM_TOKEN_URL = "https://us.edge.bamgrid.com/token"
+
+ BAM_ENTITLEMENT_URL = "https://media-entitlement.mlb.com/api/v3/jwt"
+
+ GAME_CONTENT_URL_TEMPLATE="http://statsapi.mlb.com/api/v1/game/{game_id}/content"
+
+ STREAM_URL_TEMPLATE="https://edge.svcs.mlb.com/media/{media_id}/scenarios/browser~csai"
+
+ AIRINGS_URL_TEMPLATE=(
+ "https://search-api-mlbtv.mlb.com/svc/search/v2/graphql/persisted/query/"
+ "core/Airings?variables={{%22partnerProgramIds%22%3A[%22{game_id}%22]}}"
+ )
+
+ RESOLUTIONS = AttrDict([
+ ("720p", "720p_alt"),
+ ("720p@30", "720p"),
+ ("540p", "540p"),
+ ("504p", "504p"),
+ ("360p", "360p"),
+ ("288p", "288p"),
+ ("224p", "224p")
+ ])
+
+ def __init__(
+ self,
+ username, password,
+ api_key=None,
+ client_api_key=None,
+ okta_client_id=None,
+ session_token=None,
+ access_token=None,
+ access_token_expiry=None,
+ *args, **kwargs
+ ):
+ super(MLBStreamSession, self).__init__(
+ username, password,
+ *args, **kwargs
)
+ self._state.api_key = api_key
+ self._state.client_api_key = client_api_key
+ self._state.okta_client_id = okta_client_id
+ self._state.session_token = session_token
+ self._state.access_token = access_token
+ self._state.access_token_expiry = access_token_expiry
+
- if not (self.ipid and self.fingerprint):
- raise MLBSessionException("Couldn't get ipid / fingerprint")
+ def login(self):
- logger.debug("logged in: %s" %(self.ipid))
+ AUTHN_PARAMS = {
+ "username": self.username,
+ "password": self.password,
+ "options": {
+ "multiOptionalFactorEnroll": False,
+ "warnBeforePasswordExpired": True
+ }
+ }
+ authn_response = self.session.post(
+ self.AUTHN_URL, json=AUTHN_PARAMS
+ ).json()
+ self.session_token = authn_response["sessionToken"]
+
+ # logger.debug("logged in: %s" %(self.ipid))
self.save()
@property
- def logged_in(self):
-
- logged_in_url = ("https://web-secure.mlb.com/enterworkflow.do"
- "?flowId=registration.newsletter&c_id=mlb")
- content = self.get(logged_in_url).text
- parser = lxml.etree.HTMLParser()
- data = lxml.etree.parse(StringIO(content), parser)
- if "Login/Register" in data.xpath(".//title")[0].text:
- return False
+ def headers(self):
+ return {
+ "Authorization": self.access_token
+ }
- def get_cookie(self, name):
- return requests.utils.dict_from_cookiejar(self.session.cookies).get(name)
@property
def ipid(self):
@@ -313,39 +491,43 @@ class MLBSession(object):
self.update_api_keys()
return self._state.client_api_key
+ @property
+ def okta_client_id(self):
+
+ if not self._state.get("okta_client_id"):
+ self.update_api_keys()
+ return self._state.okta_client_id
+
def update_api_keys(self):
- logger.debug("updating api keys")
- content = self.get("https://www.mlb.com/tv/g490865/").text
+ logger.debug("updating MLB api keys")
+ content = self.session.get(self.MLB_API_KEY_URL).text
parser = lxml.etree.HTMLParser()
data = lxml.etree.parse(StringIO(content), parser)
scripts = data.xpath(".//script")
for script in scripts:
if script.text and "apiKey" in script.text:
- self._state.api_key = API_KEY_RE.search(script.text).groups()[0]
+ self._state.api_key = self.API_KEY_RE.search(script.text).groups()[0]
if script.text and "clientApiKey" in script.text:
- self._state.client_api_key = CLIENT_API_KEY_RE.search(script.text).groups()[0]
+ self._state.client_api_key = self.CLIENT_API_KEY_RE.search(script.text).groups()[0]
+
+ logger.debug("updating Okta api keys")
+ content = self.session.get(self.MLB_OKTA_URL).text
+ self._state.okta_client_id = self.OKTA_CLIENT_ID_RE.search(content).groups()[0]
self.save()
@property
- def token(self):
- logger.debug("getting token")
- if not self._state.token:
- headers = {"x-api-key": self.api_key}
-
- response = self.get(
- TOKEN_URL_TEMPLATE.format(
- ipid=self.ipid, fingerprint=self.fingerprint, platform=PLATFORM
- ),
- headers=headers
- )
- self._state.token = response.text
- return self._state.token
+ def session_token(self):
+ if not self._state.session_token:
+ self.login()
+ if not self._state.session_token:
+ raise Exception("no session token")
+ return self._state.session_token
- @token.setter
- def token(self, value):
- self._state.token = value
+ @session_token.setter
+ def session_token(self, value):
+ self._state.session_token = value
@property
def access_token_expiry(self):
@@ -360,139 +542,215 @@ class MLBSession(object):
@property
def access_token(self):
- logger.debug("getting access token")
if not self._state.access_token or not self.access_token_expiry or \
self.access_token_expiry < datetime.now(tz=pytz.UTC):
-
try:
- self._state.access_token, self.access_token_expiry = self._get_access_token()
+ self.refresh_access_token()
except requests.exceptions.HTTPError:
# Clear token and then try to get a new access_token
- self.token = None
- self._state.access_token, self.access_token_expiry = self._get_access_token()
+ self.refresh_access_token(clear_token=True)
- self.save()
logger.debug("access_token: %s" %(self._state.access_token))
return self._state.access_token
- def _get_access_token(self):
+ def refresh_access_token(self, clear_token=False):
+ logger.debug("refreshing access token")
+
+ if clear_token:
+ self.session_token = None
+
+ # ----------------------------------------------------------------------
+ # Okta authentication -- used to get media entitlement later
+ # ----------------------------------------------------------------------
+ STATE = gen_random_string(64)
+ NONCE = gen_random_string(64)
+
+ AUTHZ_PARAMS = {
+ "client_id": self.okta_client_id,
+ "redirect_uri": "https://www.mlb.com/login",
+ "response_type": "id_token token",
+ "response_mode": "okta_post_message",
+ "state": STATE,
+ "nonce": NONCE,
+ "prompt": "none",
+ "sessionToken": self.session_token,
+ "scope": "openid email"
+ }
+ authz_response = self.session.get(self.AUTHZ_URL, params=AUTHZ_PARAMS)
+ authz_content = authz_response.text
+
+ for line in authz_content.split("\n"):
+ if "data.access_token" in line:
+ OKTA_ACCESS_TOKEN = line.split("'")[1].encode('utf-8').decode('unicode_escape')
+ break
+ else:
+ raise Exception(authz_content)
+
+ # ----------------------------------------------------------------------
+ # Get device assertion - used to get device token
+ # ----------------------------------------------------------------------
+ DEVICES_HEADERS = {
+ "Authorization": "Bearer %s" % (self.client_api_key),
+ "Origin": "https://www.mlb.com",
+ }
+
+ DEVICES_PARAMS = {
+ "applicationRuntime": "firefox",
+ "attributes": {},
+ "deviceFamily": "browser",
+ "deviceProfile": "macosx"
+ }
+
+ devices_response = self.session.post(
+ self.BAM_DEVICES_URL,
+ headers=DEVICES_HEADERS, json=DEVICES_PARAMS
+ ).json()
+
+ DEVICES_ASSERTION=devices_response["assertion"]
+
+ # ----------------------------------------------------------------------
+ # Get device token
+ # ----------------------------------------------------------------------
+
+ TOKEN_PARAMS = {
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
+ "latitude": "0",
+ "longitude": "0",
+ "platform": "browser",
+ "subject_token": DEVICES_ASSERTION,
+ "subject_token_type": "urn:bamtech:params:oauth:token-type:device"
+ }
+ token_response = self.session.post(
+ self.BAM_TOKEN_URL, headers=DEVICES_HEADERS, data=TOKEN_PARAMS
+ ).json()
+
+
+ DEVICE_ACCESS_TOKEN = token_response["access_token"]
+ DEVICE_REFRESH_TOKEN = token_response["refresh_token"]
+
+ # ----------------------------------------------------------------------
+ # Create session -- needed for device ID, which is used for entitlement
+ # ----------------------------------------------------------------------
+ SESSION_HEADERS = {
+ "Authorization": DEVICE_ACCESS_TOKEN,
+ "User-agent": USER_AGENT,
+ "Origin": "https://www.mlb.com",
+ "Accept": "application/vnd.session-service+json; version=1",
+ "Accept-Encoding": "gzip, deflate, br",
+ "Accept-Language": "en-US,en;q=0.5",
+ "x-bamsdk-version": self.BAM_SDK_VERSION,
+ "x-bamsdk-platform": self.PLATFORM,
+ "Content-type": "application/json",
+ "TE": "Trailers"
+ }
+ session_response = self.session.get(
+ self.BAM_SESSION_URL,
+ headers=SESSION_HEADERS
+ ).json()
+ DEVICE_ID = session_response["device"]["id"]
+
+ # ----------------------------------------------------------------------
+ # Get entitlement token
+ # ----------------------------------------------------------------------
+ ENTITLEMENT_PARAMS={
+ "os": self.PLATFORM,
+ "did": DEVICE_ID,
+ "appname": "mlbtv_web"
+ }
+
+ ENTITLEMENT_HEADERS = {
+ "Authorization": "Bearer %s" % (OKTA_ACCESS_TOKEN),
+ "Origin": "https://www.mlb.com",
+ "x-api-key": self.api_key
+
+ }
+ entitlement_response = self.session.get(
+ self.BAM_ENTITLEMENT_URL,
+ headers=ENTITLEMENT_HEADERS,
+ params=ENTITLEMENT_PARAMS
+ )
+
+ ENTITLEMENT_TOKEN = entitlement_response.content
+
+ # ----------------------------------------------------------------------
+ # Finally (whew!) get access token using entitlement token
+ # ----------------------------------------------------------------------
headers = {
"Authorization": "Bearer %s" % (self.client_api_key),
"User-agent": USER_AGENT,
"Accept": "application/vnd.media-service+json; version=1",
- "x-bamsdk-version": BAM_SDK_VERSION,
- "x-bamsdk-platform": PLATFORM,
+ "x-bamsdk-version": self.BAM_SDK_VERSION,
+ "x-bamsdk-platform": self.PLATFORM,
"origin": "https://www.mlb.com"
}
data = {
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"platform": "browser",
- "setCookie": "false",
- "subject_token": self.token,
- "subject_token_type": "urn:ietf:params:oauth:token-type:jwt"
+ "subject_token": ENTITLEMENT_TOKEN,
+ "subject_token_type": "urn:bamtech:params:oauth:token-type:account"
}
- response = self.post(
- ACCESS_TOKEN_URL,
+ response = self.session.post(
+ self.BAM_TOKEN_URL,
data=data,
headers=headers
)
+ # from requests_toolbelt.utils import dump
+ # print(dump.dump_all(response).decode("utf-8"))
response.raise_for_status()
token_response = response.json()
- token_expiry = datetime.now(tz=pytz.UTC) + \
+ self.access_token_expiry = datetime.now(tz=pytz.UTC) + \
timedelta(seconds=token_response["expires_in"])
-
- return token_response["access_token"], token_expiry
+ self._state.access_token = token_response["access_token"]
+ self.save()
def content(self, game_id):
- return self.get(GAME_CONTENT_URL_TEMPLATE.format(game_id=game_id)).json()
+ return self.session.get(
+ self.GAME_CONTENT_URL_TEMPLATE.format(game_id=game_id)).json()
# def feed(self, game_id):
- # return self.get(GAME_FEED_URL.format(game_id=game_id)).json()
+ # return self.session.get(GAME_FEED_URL.format(game_id=game_id)).json()
- @memo(region="short")
- def schedule(
- self,
- sport_id=None,
- start=None,
- end=None,
- game_type=None,
- team_id=None,
- game_id=None,
- ):
+ @memo(region="long")
+ def teams(self, sport_code="mlb", season=None):
- logger.debug(
- "getting schedule: %s, %s, %s, %s, %s, %s" %(
- sport_id,
- start,
- end,
- game_type,
- team_id,
- game_id
- )
- )
- url = SCHEDULE_TEMPLATE.format(
- sport_id = sport_id if sport_id else "",
- start = start.strftime("%Y-%m-%d") if start else "",
- end = end.strftime("%Y-%m-%d") if end else "",
- game_type = game_type if game_type else "",
- team_id = team_id if team_id else "",
- game_id = game_id if game_id else ""
- )
- with self.cache_responses_short():
- return self.get(url).json()
-
- @memo(region="short")
- def get_epgs(self, game_id, title="MLBTV"):
- schedule = self.schedule(game_id=game_id)
- try:
- # Get last date for games that have been rescheduled to a later date
- game = schedule["dates"][-1]["games"][0]
- except KeyError:
- logger.debug("no game data")
- return
- epgs = game["content"]["media"]["epg"]
+ if sport_code != "mlb":
+ media_title = "MiLBTV"
+ raise MLBPlayException("Sorry, MiLB.tv streams are not yet supported")
- if not isinstance(epgs, list):
- epgs = [epgs]
+ sports_url = (
+ "http://statsapi.mlb.com/api/v1/sports"
+ )
+ with state.session.cache_responses_long():
+ sports = self.session.get(sports_url).json()
- return [ e for e in epgs if (not title) or title == e["title"] ]
+ sport = next(s for s in sports["sports"] if s["code"] == sport_code)
- def get_media(self,
- game_id,
- media_id=None,
- title="MLBTV",
- preferred_stream=None,
- call_letters=None):
+ # season = game_date.year
+ teams_url = (
+ "http://statsapi.mlb.com/api/v1/teams"
+ "?sportId={sport}&{season}".format(
+ sport=sport["id"],
+ season=season if season else ""
+ )
+ )
- logger.debug("geting media for game %d" %(game_id))
+ # raise Exception(self.session.get(teams_url).json())
+ with state.session.cache_responses_long():
+ teams = AttrDict(
+ (team["abbreviation"].lower(), team["id"])
+ for team in sorted(self.session.get(teams_url).json()["teams"],
+ key=lambda t: t["fileCode"])
+ )
- epgs = self.get_epgs(game_id, title)
- for epg in epgs:
- for item in epg["items"]:
- if (not preferred_stream
- or (item.get("mediaFeedType", "").lower() == preferred_stream)
- ) and (
- not call_letters
- or (item.get("callLetters", "").lower() == call_letters)
- ) and (
- not media_id
- or (item.get("mediaId", "").lower() == media_id)
- ):
- logger.debug("found preferred stream")
- yield item
- else:
- if len(epg["items"]):
- logger.debug("using non-preferred stream")
- yield epg["items"][0]
- # raise StopIteration
+ return teams
def airings(self, game_id):
- airings_url = AIRINGS_URL_TEMPLATE.format(game_id = game_id)
- airings = self.get(
+ airings_url = self.AIRINGS_URL_TEMPLATE.format(game_id = game_id)
+ airings = self.session.get(
airings_url
).json()["data"]["Airings"]
return airings
@@ -504,7 +762,7 @@ class MLBSession(object):
airing = next(a for a in self.airings(game_id)
if a["mediaId"] == media_id)
except StopIteration:
- raise MLBSessionException("No airing for media %s" %(media_id))
+ raise StreamSessionException("No airing for media %s" %(media_id))
start_timestamps = []
try:
@@ -568,32 +826,270 @@ class MLBSession(object):
]))
return timestamps
- def get_stream(self, media_id):
+ def get_stream(self, media):
- # try:
- # media = next(self.get_media(game_id))
- # except StopIteration:
- # logger.debug("no media for stream")
- # return
- # media_id = media["mediaId"]
+ media_id = media.get("mediaId", media.get("guid"))
headers={
"Authorization": self.access_token,
"User-agent": USER_AGENT,
"Accept": "application/vnd.media-service+json; version=1",
"x-bamsdk-version": "3.0",
- "x-bamsdk-platform": PLATFORM,
+ "x-bamsdk-platform": self.PLATFORM,
"origin": "https://www.mlb.com"
}
- stream_url = STREAM_URL_TEMPLATE.format(media_id=media_id)
+ stream_url = self.STREAM_URL_TEMPLATE.format(media_id=media_id)
logger.info("getting stream %s" %(stream_url))
- stream = self.get(
+ stream = self.session.get(
stream_url,
headers=headers
).json()
logger.debug("stream response: %s" %(stream))
if "errors" in stream and len(stream["errors"]):
return None
+ stream = Stream(stream)
+ stream.url = stream["stream"]["complete"]
return stream
-__all__ = ["MLBSession", "MLBSessionException"]
+
+
+class NHLStreamSession(BAMStreamSessionMixin, StreamSession):
+
+ AUTH = b"web_nhl-v1.0.0:2d1d846ea3b194a18ef40ac9fbce97e3"
+
+ SCHEDULE_TEMPLATE = (
+ "https://statsapi.web.nhl.com/api/v1/schedule"
+ "?sportId={sport_id}&startDate={start}&endDate={end}"
+ "&gameType={game_type}&gamePk={game_id}"
+ "&teamId={team_id}"
+ "&hydrate=linescore,team,game(content(summary,media(epg)),tickets)"
+ )
+
+ RESOLUTIONS = AttrDict([
+ ("720p", "720p"),
+ ("540p", "540p"),
+ ("504p", "504p"),
+ ("360p", "360p"),
+ ("288p", "288p"),
+ ("216p", "216p")
+ ])
+
+ def __init__(
+ self,
+ username, password,
+ session_key=None,
+ *args, **kwargs
+ ):
+ super(NHLStreamSession, self).__init__(
+ username, password,
+ *args, **kwargs
+ )
+ self.session_key = session_key
+
+
+ def login(self):
+
+ if self.logged_in:
+ logger.info("already logged in")
+ return
+
+ auth = base64.b64encode(self.AUTH).decode("utf-8")
+
+ token_url = "https://user.svc.nhl.com/oauth/token?grant_type=client_credentials"
+
+ headers = {
+ "Authorization": f"Basic {auth}",
+ # "Referer": "https://www.nhl.com/login/freeGame?forwardUrl=https%3A%2F%2Fwww.nhl.com%2Ftv%2F2018020013%2F221-2000552%2F61332703",
+ "Accept": "application/json, text/javascript, */*; q=0.01",
+ "Accept-Language": "en-US,en;q=0.5",
+ "Accept-Encoding": "gzip, deflate, br",
+ "Origin": "https://www.nhl.com"
+ }
+
+ res = self.session.post(token_url, headers=headers)
+ self.session_token = json.loads(res.text)["access_token"]
+
+ login_url="https://gateway.web.nhl.com/ws/subscription/flow/nhlPurchase.login"
+
+ auth = base64.b64encode(b"web_nhl-v1.0.0:2d1d846ea3b194a18ef40ac9fbce97e3")
+
+ params = {
+ "nhlCredentials": {
+ "email": self.username,
+ "password": self.password
+ }
+ }
+
+ headers = {
+ "Authorization": self.session_token,
+ "Origin": "https://www.nhl.com",
+ # "Referer": "https://www.nhl.com/login/freeGame?forwardUrl=https%3A%2F%2Fwww.nhl.com%2Ftv%2F2018020013%2F221-2000552%2F61332703",
+ }
+
+ res = self.session.post(
+ login_url,
+ json=params,
+ headers=headers
+ )
+ self.save()
+ print(res.status_code)
+ return (res.status_code == 200)
+
+
+ @property
+ def logged_in(self):
+
+ logged_in_url = "https://account.nhl.com/ui/AccountProfile"
+ content = self.session.get(logged_in_url).text
+ # FIXME: this is gross
+ if '"NHL Account - Profile"' in content:
+ return True
+ return False
+
+ @property
+ def session_key(self):
+ return self._state.session_key
+
+ @session_key.setter
+ def session_key(self, value):
+ self._state.session_key = value
+
+ @property
+ def token(self):
+ return self._state.token
+
+ @token.setter
+ def token(self, value):
+ self._state.token = value
+
+
+ @memo(region="long")
+ def teams(self, sport_code="mlb", season=None):
+
+ teams_url = (
+ "https://statsapi.web.nhl.com/api/v1/teams"
+ "?{season}".format(
+ season=season if season else ""
+ )
+ )
+
+ # raise Exception(self.session.get(teams_url).json())
+ with state.session.cache_responses_long():
+ teams = AttrDict(
+ (team["abbreviation"].lower(), team["id"])
+ for team in sorted(self.session.get(teams_url).json()["teams"],
+ key=lambda t: t["abbreviation"])
+ )
+
+ return teams
+
+
+ def get_stream(self, media):
+
+ url = "https://mf.svc.nhl.com/ws/media/mf/v2.4/stream"
+
+ event_id = media["eventId"]
+ if not self.session_key:
+ logger.info("getting session key")
+
+
+ params = {
+ "eventId": event_id,
+ "format": "json",
+ "platform": "WEB_MEDIAPLAYER",
+ "subject": "NHLTV",
+ "_": "1538708097285"
+ }
+
+ res = self.session.get(
+ url,
+ params=params
+ )
+ j = res.json()
+ logger.trace(json.dumps(j, sort_keys=True,
+ indent=4, separators=(',', ': ')))
+
+ self.session_key = j["session_key"]
+ self.save()
+
+ params = {
+ "contentId": media["mediaPlaybackId"],
+ "playbackScenario": "HTTP_CLOUD_WIRED_WEB",
+ "sessionKey": self.session_key,
+ "auth": "response",
+ "platform": "WEB_MEDIAPLAYER",
+ "_": "1538708097285"
+ }
+ res = self.session.get(
+ url,
+ params=params
+ )
+ j = res.json()
+ logger.trace(json.dumps(j, sort_keys=True,
+ indent=4, separators=(',', ': ')))
+
+ try:
+ media_auth = next(x["attributeValue"]
+ for x in j["session_info"]["sessionAttributes"]
+ if x["attributeName"] == "mediaAuth_v2")
+ except KeyError:
+ raise StreamSessionException(f"No stream found for event {event_id}")
+
+ self.cookies.set_cookie(
+ Cookie(0, 'mediaAuth_v2', media_auth,
+ '80', '80', '.nhl.com',
+ None, None, '/', True, False, 4102444800, None, None, None, {}),
+ )
+
+ stream = Stream(j["user_verified_event"][0]["user_verified_content"][0]["user_verified_media_item"][0])
+
+ return stream
+
+
+def new(provider, *args, **kwargs):
+ session_class = globals().get(f"{provider.upper()}StreamSession")
+ return session_class.new(*args, **kwargs)
+
+PROVIDERS_RE = re.compile(r"(.+)StreamSession$")
+PROVIDERS = [ k.replace("StreamSession", "").lower()
+ for k in globals() if PROVIDERS_RE.search(k) ]
+
+
+def main():
+
+ from . import state
+ from . import utils
+ import argparse
+
+ global options
+
+ parser = argparse.ArgumentParser()
+ group = parser.add_mutually_exclusive_group()
+ group.add_argument("-v", "--verbose", action="count", default=0,
+ help="verbose logging")
+ group.add_argument("-q", "--quiet", action="count", default=0,
+ help="quiet logging")
+ options, args = parser.parse_known_args()
+
+ utils.setup_logging(options.verbose - options.quiet)
+
+ # state.session = MLBStreamSession.new()
+ # raise Exception(state.session.token)
+ raise Exception(PROVIDERS)
+
+ # state.session = NHLStreamSession.new()
+ # raise Exception(state.session.session_key)
+
+
+ # schedule = state.session.schedule(game_id=2018020020)
+ # media = self.session.get_epgs(game_id=2018020020)
+ # print(json.dumps(list(media), sort_keys=True,
+ # indent=4, separators=(',', ': ')))
+
+
+if __name__ == "__main__":
+ main()
+
+
+
+__all__ = ["MLBStreamSession", "StreamSessionException"]
diff --git a/mlbstreamer/util.py b/mlbstreamer/util.py
deleted file mode 100644
index 3d7cd18..0000000
--- a/mlbstreamer/util.py
+++ /dev/null
@@ -1,9 +0,0 @@
-import argparse
-from datetime import datetime
-
-def valid_date(s):
- try:
- return datetime.strptime(s, "%Y-%m-%d").date()
- except ValueError:
- msg = "Not a valid date: '{0}'.".format(s)
- raise argparse.ArgumentTypeError(msg)
diff --git a/mlbstreamer/utils.py b/mlbstreamer/utils.py
new file mode 100644
index 0000000..5faf324
--- /dev/null
+++ b/mlbstreamer/utils.py
@@ -0,0 +1,69 @@
+import logging
+import sys
+import argparse
+from datetime import datetime
+from orderedattrdict import AttrDict
+
+LOG_LEVEL_DEFAULT=3
+LOG_LEVELS = [
+ "critical",
+ "error",
+ "warning",
+ "info",
+ "debug",
+ "trace"
+]
+def setup_logging(level=0, handlers=[], quiet_stdout=False):
+
+ level = LOG_LEVEL_DEFAULT + level
+ if level < 0 or level >= len(LOG_LEVELS):
+ raise Exception("bad log level: %d" %(level))
+ # add "trace" log level
+ TRACE_LEVEL_NUM = 9
+ logging.addLevelName(TRACE_LEVEL_NUM, "TRACE")
+ logging.TRACE = TRACE_LEVEL_NUM
+ def trace(self, message, *args, **kws):
+ if self.isEnabledFor(TRACE_LEVEL_NUM):
+ self._log(TRACE_LEVEL_NUM, message, args, **kws)
+ logging.Logger.trace = trace
+
+ if isinstance(level, str):
+ level = getattr(logging, level.upper())
+ else:
+ level = getattr(logging, LOG_LEVELS[level].upper())
+
+ if not isinstance(handlers, list):
+ handlers = [handlers]
+
+ logger = logging.getLogger()
+ formatter = logging.Formatter(
+ "%(asctime)s [%(module)16s:%(lineno)-4d] [%(levelname)8s] %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S"
+ )
+ logger.setLevel(level)
+ outh = logging.StreamHandler(sys.stdout)
+ outh.setLevel(logging.ERROR if quiet_stdout else level)
+
+ handlers.insert(0, outh)
+ # if not handlers:
+ # handlers = [logging.StreamHandler(sys.stdout)]
+ for handler in handlers:
+ handler.setFormatter(formatter)
+ logger.addHandler(handler)
+
+ # logger = logging.basicConfig(
+ # level=level,
+ # format="%(asctime)s [%(module)16s:%(lineno)-4d] [%(levelname)8s] %(message)s",
+ # datefmt="%Y-%m-%d %H:%M:%S"
+ # )
+
+ logging.getLogger("requests").setLevel(level+1)
+ logging.getLogger("urllib3").setLevel(level+1)
+
+
+def valid_date(s):
+ try:
+ return datetime.strptime(s, "%Y-%m-%d").date()
+ except ValueError:
+ msg = "Not a valid date: '{0}'.".format(s)
+ raise argparse.ArgumentTypeError(msg)
diff --git a/setup.py b/setup.py
index e317fb9..7f76820 100644
--- a/setup.py
+++ b/setup.py
@@ -8,7 +8,7 @@ from glob import glob
name = "mlbstreamer"
setup(name=name,
- version="0.0.10",
+ version="0.0.11.dev0",
description="MLB.tv Stream Browser",
author="Tony Cebzanov",
author_email="tonycpsu@gmail.com",
@@ -20,6 +20,9 @@ setup(name=name,
],
license = "GPLv2",
packages=find_packages(),
+ data_files=[
+ ('share/doc/%s' % name, ["docs/config.yaml.sample"]),
+ ],
include_package_data=True,
install_requires = [
"six",
@@ -35,7 +38,7 @@ setup(name=name,
"prompt_toolkit",
"urwid",
"urwid_utils>=0.1.2",
- "panwid>=0.2.4"
+ "panwid>=0.2.5"
],
test_suite="test",
entry_points = {
Reply to: