Package: release.debian.org Severity: normal User: release.debian....@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 <s...@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 <s...@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 <s...@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: cha...@me.com + password: changeme + nhl: # NHL.tv + username: cha...@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="tonyc...@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 = {