Package: release.debian.org
Severity: normal
User: release.debian....@packages.debian.org
Usertags: unblock

Dear Release Masters,

#926165 describes how mlbstreamer 0.0.10-3, as present in testing, is
completely non-fonctional due to recent changes in the online service
it targets.

Version 0.0.11.dev0+git20190330-1 fixes that, but of course that's a new
upstream release and the debdiff (attached) is quite large. On the plus
side, it doesn't require new dependencies, and mlbstreamer is a leaf
package with no reverse-dependencies.

Do you think maybe it could be unblocked ? If you consider this a bad
idea, no worries :)

unblock mlbstreamer/0.0.11.dev0+git20190330-1

-- System Information:
Debian Release: buster/sid
  APT prefers unstable
  APT policy: (500, 'unstable'), (1, 'experimental')
Architecture: amd64 (x86_64)
Foreign Architectures: i386

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

Reply via email to