[Date Prev][Date Next] [Thread Prev][Thread Next] [Date Index] [Thread Index]

Bug#926507: marked as done (unblock: mlbstreamer/0.0.11.dev0+git20190330-1)



Your message dated Sun, 5 May 2019 15:33:31 +0200
with message-id <01d1fcad-44cf-5076-124e-e4b365c2c45a@debian.org>
and subject line Re: unblock: mlbstreamer/0.0.11.dev0+git20190330-1
has caused the Debian Bug report #926507,
regarding unblock: mlbstreamer/0.0.11.dev0+git20190330-1
to be marked as done.

This means that you claim that the problem has been dealt with.
If this is not the case it is now your responsibility to reopen the
Bug report if necessary, and/or fix the problem forthwith.

(NB: If you are a system administrator and have no idea what this
message is talking about, this may indicate a serious mail system
misconfiguration somewhere. Please contact owner@bugs.debian.org
immediately.)


-- 
926507: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=926507
Debian Bug Tracking System
Contact owner@bugs.debian.org with problems
--- Begin Message ---
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 = {

--- End Message ---
--- Begin Message ---
Hi Seb,

On Sat, 06 Apr 2019 11:00:32 +0200 Sebastien Delafond <seb@debian.org>
wrote:
> 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 :)

This is indeed a bit too much after the freeze.

Paul

Attachment: signature.asc
Description: OpenPGP digital signature


--- End Message ---

Reply to: