diff --git a/README.md b/README.md index 954d3f3..eb16f34 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,25 @@ Live Games: | | 22:30: Calgary Flames (CGY) at San Jose Sharks (SJS) | | | away, home 19:30: Montréal Canadiens (MTL) at Tampa Bay Lightning (TBL) | 1-3 | Final | away, french, home 19:30: Philadelphia Flyers (PHI) at Florida Panthers (FLA) | 2-3 | Final | away, home + +```` + +With `short_feed_names` option to reduce some clutter (some games have condensed, recap feeds available): ```` + 2017-12-31 | Score | State | Feeds +----------------------------------------------------------------|-------|-----------|------------ +Live Games: | | | +20:00: New York Islanders (NYI) at Colorado Avalanche (COL) | 1-5 | 07:05 3rd | a/h +20:00: San Jose Sharks (SJS) at Dallas Stars (DAL) | 0-6 | 02:25 3rd | a/h +21:00: Chicago Blackhawks (CHI) at Calgary Flames (CGY) | 2-3 | 09:29 2nd | a/nat +----- | | | +15:30: Toronto Maple Leafs (TOR) at Vegas Golden Knights (VGK) | 3-6 | Final | a/fr/h cnd/rcp +16:00: Arizona Coyotes (ARI) at Anaheim Ducks (ANA) | 2-5 | Final | a/h cnd/rcp +18:00: Tampa Bay Lightning (TBL) at Columbus Blue Jackets (CBJ) | 5-0 | Final | a/h cnd/rcp +19:00: Winnipeg Jets (WPG) at Edmonton Oilers (EDM) | 5-0 | Final | nat rcp +19:00: Pittsburgh Penguins (PIT) at Detroit Red Wings (DET) | 1-4 | Final | a/h +```` + This project incorporates some code modified from the following projects: @@ -74,7 +92,7 @@ Some things you may want to set: * username: NHL.tv account username * password: NHL.tv account password -* use_rogers: set to true if your account goes through Rogers +* use_rogers: set to true if your NHL streaming account goes through Rogers * favs: a comma-separated list of team codes which are 1) highlighted in the game data and 2) can be filtered on using the --filter option to show only the favourite team(s) * scores: a boolean specifying whether or not you want to see scores in the game information. Spoilers. * resolution: the stream quality (passed in to streamlink). Use 'best' for full HD at 60 frames/sec. @@ -83,7 +101,7 @@ Some things you may want to set: ## TODO -* add `mlbv` to view baseball games +* add `mlbv` to view baseball games. This should be fairly simple since they both use the MLBAM infrastructure. ## Usage @@ -97,7 +115,7 @@ Running `nhlv` without options shows you the status of today's games. ### Playing a Live or Archived Game -If you pass the `-t/--team TEAM` option, the stream is launched for the given team. By default the 'home' feed +If you pass the `-t/--team TEAM` option, the stream is launched for the given team. By default the local feed for the given team is chosen - i.e., it will follow the home/away feed appropriate for the team so that you get the local team feed. You can override the feed using the `-f/--feed` option. This works for either live games or for archived games (e.g. if you use the `--date` option to select an earlier date). @@ -108,19 +126,19 @@ Example: nhlv --yesterday -t wpg # play yesterday's jets game (see below for options on specifying dates) -### Recording +### Fetching -If you pass the `-r/--record` option, instead of launching the video player, the selected stream is saved to +If you pass the `-f/--fetch` option, instead of launching the video player, the selected stream is saved to disk. The stream is named to convention: `---.mp4`. Example: `2017-12-27-edm-wpg-national.mp4`. -You can select the stream for record and then manually launch your video player at a later time while the -stream is still recording. +You can select the stream for fetch, then manually launch your video player at a later time while the +stream is being saved to file. Example: - nhlv --team wpg --record # record the live jets game + nhlv --team wpg --fetch # fetch the live jets game to disk. Most players let you view while downloading ### Highlights: Recap or Condensed Games @@ -156,8 +174,10 @@ Note: the common options have both short and long options. Both are shown in the #### Live Games - nhlv --team wpg # play the live jets game - nhltv -t wpg --feed national # choose the national feed + nhlv --team wpg # play the live jets game. The feed is chosen based on jets being home vs. away + nhltv -t wpg --feed national # play live game, choose the national feed + nhltv -t wpg --feed away # play live game, choose the away feed. If jets are the home team this would choose + # the opponent's feed #### Archived Games @@ -171,12 +191,12 @@ Use the `--feed` option to select the highlight feed (`recap` or `condensed`): nhlv --yesterday -t wpg --feed condensed # condensed feed nhlv --yesterday -t wpg -f recap # recap feed -#### Recording +#### Fetch In these examples the game is save to a .mp4 file in the current directory. - nhlv --team wpg --record # record the live jets game. - nhlv --yesterday -t wpg -f recap --record # record yesterday's recap + nhlv --team wpg --fetch + nhlv --yesterday -t wpg -f recap --fetch # fetch yesterday's recap #### Using `--days` for Schedule View diff --git a/config b/config index 0e61879..3986630 100644 --- a/config +++ b/config @@ -22,9 +22,22 @@ # Example: favs=wpg,ott #favs= +# Favourite team colour, shown in game listings to highlight the favourite teams. +# Leave this blank to disable +# Available colours: +# black, red, green, orange, blue, purple, cyan, lightgrey, darkgrey, +# lightred, lightgreen, yellow, lightblue, pink, lightcyan +#fav_colour=cyan + +# Colour used to highlight games in 'critical' state +#game_critical_colour=yellow + # Show scores. Scores are hidden by default. If this is set to true then scores are shown. #scores=false +# Use short feed names in game listings +use_short_feeds=true + # Bandwidth/stream resolution for streamlink. One of 'worst', '360p', '540p', '720p_alt', '720p' 'best' # Note: 720p (best) is the 60fps stream. The 720p_alt is a lower framerate. # Fallback streams can be specified by using a comma-separated list, e.g.: 720p,540p,best @@ -40,6 +53,14 @@ # Audio player for audio-only feeds (not implemented yet): #audio_player=mpv +# Use streamlink for highlights. If false will send url direct to video_player (no resolution selection) +streamlink_highlights=true +# Passthrough the HLS stream to the player for highlights: allows seeking +streamlink_passthrough_highlights=true + +# Passthrough the HLS stream to the player for live/archived games +streamlink_passthrough=false + # Turn on extra debugging information #debug=false diff --git a/mlbam/config.py b/mlbam/config.py index 1eb9f16..7efe563 100644 --- a/mlbam/config.py +++ b/mlbam/config.py @@ -30,6 +30,7 @@ class Config: 'use_rogers': 'false', 'favs': '', 'fav_colour': 'cyan', + 'use_short_feeds': 'true', 'filter': 'false', 'cdn': 'akamai', 'resolution': 'best', diff --git a/mlbam/gamedata.py b/mlbam/gamedata.py index 4fcedea..dbbbfbc 100644 --- a/mlbam/gamedata.py +++ b/mlbam/gamedata.py @@ -21,15 +21,22 @@ # this map is used to transform the statsweb feed name to something shorter FEEDTYPE_MAP = { - 'away': 'away', - 'home': 'home', + 'away': 'a', + 'home': 'h', 'french': 'fr', - 'national': 'national', - 'condensed': 'condensed', - 'recap': 'recap', + 'national': 'nat', + 'condensed': 'cnd', + 'recap': 'rcp', } +def get_feedtype_keystring(): + reverse_list = list() + for longkey in FEEDTYPE_MAP: + reverse_list.append('{}:{}'.format(FEEDTYPE_MAP[longkey], longkey)) + return ', '.join(reverse_list) + + def is_fav(game_rec): if 'favourite' in game_rec: return game_rec['favourite'] @@ -84,55 +91,26 @@ class NHLGameData(GameData): def __init__(self): GameData.__init__(self) - def show_game_data(self, game_date, num_days=1): - game_data_list = list() - for i in range(0, num_days): - game_data = self.get_game_data(game_date) - if game_data is not None: - game_data_list.append(game_data) - if len(game_data_list) > 1: - print('') # add line feed between days - live_game_pks = list() - for game_pk in game_data: - if game_data[game_pk]['abstractGameState'] == 'Live': - if filter_favs(game_data[game_pk]) is not None: - live_game_pks.append(game_pk) - - # print header - date_hdr = '{:7}{}'.format('','{}'.format(game_date)) - show_scores = config.CONFIG.parser.getboolean('scores') - if show_scores: - print("{:63} | {:^5} | {:^9} | {}".format(date_hdr, 'Score', 'State', 'Feeds')) - print("{}|{}|{}|{}".format('-' * 64, '-' * 7, '-' * 11, '-' * 12)) + def __get_feeds_for_display(self, game_rec): + non_highlight_feeds = list() + use_short_feeds = config.CONFIG.parser.getboolean('use_short_feeds', True) + for feed in sorted(game_rec['feed'].keys()): + if feed not in config.HIGHLIGHT_FEEDTYPES: + if use_short_feeds: + non_highlight_feeds.append(self.convert_feedtype_to_short(feed)) else: - print("{:63} | {:^9} | {}".format(date_hdr, 'State', 'Feeds')) - print("{}|{}|{}".format('-' * 64, '-' * 11, '-' * 12)) - - if len(live_game_pks) > 0: - if show_scores: - print("{:63} |{}|{}|{}".format('Live Games:', ' ' * 7, ' ' * 11, ' ' * 12)) - else: - print("{:63} |{}|{}".format('Live Games:', ' ' * 11, ' ' * 12)) - for game_pk in live_game_pks: - if filter_favs(game_data[game_pk]) is not None: - self.show_game_details(game_pk, game_data[game_pk]) - if show_scores: - print("{:63} |{}|{}|{}".format('-----', ' ' * 7, ' ' * 11, ' ' * 12)) - else: - print("{:63} |{}|{}".format('-----', ' ' * 11, ' ' * 12)) - for game_pk in game_data: - if game_data[game_pk]['abstractGameState'] != 'Live': - if filter_favs(game_data[game_pk]) is not None: - self.show_game_details(game_pk, game_data[game_pk]) - else: - LOG.info("No game data for {}".format(game_date)) - - game_date = datetime.strftime(datetime.strptime(game_date, "%Y-%m-%d") + timedelta(days=1), "%Y-%m-%d") - - return game_data_list + non_highlight_feeds.append(feed) + highlight_feeds = list() + for feed in game_rec['feed'].keys(): + if feed in config.HIGHLIGHT_FEEDTYPES: + if use_short_feeds: + highlight_feeds.append(self.convert_feedtype_to_short(feed)) + else: + highlight_feeds.append(feed) + return '{:7} {}'.format('/'.join(non_highlight_feeds), '/'.join(highlight_feeds)) @staticmethod - def get_game_data(date_str=None, overwrite_json=True): + def _get_game_data(date_str=None, overwrite_json=True): if date_str is None: date_str = time.strftime("%Y-%m-%d") if config.SAVE_JSON_FILE_BY_TIMESTAMP: @@ -161,7 +139,7 @@ def get_game_data(date_str=None, overwrite_json=True): game_data = dict() # we return this dictionary if json_data['dates'] is None or len(json_data['dates']) < 1: - LOG.debug("get_game_data: no game data for {}".format(date_str)) + LOG.debug("_get_game_data: no game data for {}".format(date_str)) return None for game in json_data['dates'][0]['games']: @@ -225,6 +203,54 @@ def get_game_data(date_str=None, overwrite_json=True): game_rec['feed'][feedtype]['playback_url'] = playback_item['url'] return game_data + def show_game_data(self, game_date, num_days=1): + game_data_list = list() + for i in range(0, num_days): + game_data = self._get_game_data(game_date) + if game_data is not None: + game_data_list.append(game_data) + if len(game_data_list) > 1: + print('') # add line feed between days + live_game_pks = list() + for game_pk in game_data: + if game_data[game_pk]['abstractGameState'] == 'Live': + if filter_favs(game_data[game_pk]) is not None: + live_game_pks.append(game_pk) + + # print header + date_hdr = '{:7}{}'.format('','{}'.format(game_date)) + show_scores = config.CONFIG.parser.getboolean('scores') + if show_scores: + print("{:63} | {:^5} | {:^9} | {}".format(date_hdr, 'Score', 'State', 'Feeds')) + print("{}|{}|{}|{}".format('-' * 64, '-' * 7, '-' * 11, '-' * 12)) + else: + print("{:63} | {:^9} | {}".format(date_hdr, 'State', 'Feeds')) + print("{}|{}|{}".format('-' * 64, '-' * 11, '-' * 12)) + + if len(live_game_pks) > 0: + if show_scores: + print("{:63} |{}|{}|{}".format('Live Games:', ' ' * 7, ' ' * 11, ' ' * 12)) + else: + print("{:63} |{}|{}".format('Live Games:', ' ' * 11, ' ' * 12)) + for game_pk in live_game_pks: + if filter_favs(game_data[game_pk]) is not None: + self.show_game_details(game_pk, game_data[game_pk]) + if show_scores: + print("{:63} |{}|{}|{}".format('-----', ' ' * 7, ' ' * 11, ' ' * 12)) + else: + print("{:63} |{}|{}".format('-----', ' ' * 11, ' ' * 12)) + for game_pk in game_data: + if game_data[game_pk]['abstractGameState'] != 'Live': + if filter_favs(game_data[game_pk]) is not None: + self.show_game_details(game_pk, game_data[game_pk]) + # print(' ' * 5, get_feedtype_keystring()) + else: + LOG.info("No game data for {}".format(game_date)) + + game_date = datetime.strftime(datetime.strptime(game_date, "%Y-%m-%d") + timedelta(days=1), "%Y-%m-%d") + + return game_data_list + def show_game_details(self, game_pk, game_rec): color_on = '' color_off = '' @@ -258,25 +284,20 @@ def show_game_details(self, game_pk, game_rec): game_rec['linescore']['currentPeriodOrdinal']) # else: # game_state = 'Pending' - short_feeds = list() - for feed in game_rec['feed'].keys(): - short_feeds.append(self.convert_feedtype_to_short(feed)) - short_feed_str = ', '.join(sorted(short_feeds)) if config.CONFIG.parser.getboolean('scores'): score = '' if game_rec['abstractGameState'] not in ('Preview', ): score = '{}-{}'.format(game_rec['away_score'], game_rec['home_score']) - print("{0}{2:<63}{1} | {0}{3:^5}{1} | {4}{5:<9}{6} | {0}{7}{1}".format(color_on, color_off, + print("{0}{2:<63}{1} | {0}{3:^5}{1} | {4}{5:>9}{6} | {0}{7}{1}".format(color_on, color_off, game_info_str, score, - game_state_color_on, - game_state, - game_state_color_off, - short_feed_str)) - #', '.join(sorted(game_rec['feed'].keys())))) + game_state_color_on, + game_state, + game_state_color_off, + self.__get_feeds_for_display(game_rec))) else: print("{0}{2:<63}{1} | {0}{3:^9}{1} | {0}{4}{1}".format(color_on, color_off, game_info_str, game_state, - ', '.join(sorted(game_rec['feed'].keys())))) + self.__get_feeds_for_display(game_rec))) if config.CONFIG.parser.getboolean('debug') and config.CONFIG.parser.getboolean('verbose'): for feedtype in game_rec['feed']: print(' {}: {} [game_pk:{}, mediaPlaybackId:{}]'.format(feedtype, diff --git a/mlbam/stream.py b/mlbam/stream.py index 9143644..2f7535b 100644 --- a/mlbam/stream.py +++ b/mlbam/stream.py @@ -144,7 +144,7 @@ def save_playlist_to_file(stream_url, media_auth): LOG.debug('save_playlist_to_file: {}'.format(playlist)) -def play_stream(game_data, team_to_play, feedtype, date_str, record, login_func): +def play_stream(game_data, team_to_play, feedtype, date_str, fetch, login_func): game_rec = None for game_pk in game_data: if team_to_play in (game_data[game_pk]['away_abbrev'], game_data[game_pk]['home_abbrev']): @@ -158,7 +158,7 @@ def play_stream(game_data, team_to_play, feedtype, date_str, record, login_func) playback_url = find_highlight_url_for_team(game_rec, feedtype) if playback_url is None: util.die("No playback url for feed '{}'".format(feedtype)) - play_highlight(playback_url, get_recording_filename(date_str, game_rec, feedtype, record)) + play_highlight(playback_url, get_fetch_filename(date_str, game_rec, feedtype, fetch)) else: # handle full game (live or archive) # this is the only feature requiring an authenticated session @@ -177,7 +177,7 @@ def play_stream(game_data, team_to_play, feedtype, date_str, record, login_func) if config.DEBUG: save_playlist_to_file(stream_url, media_auth) streamlink(stream_url, media_auth, - get_recording_filename(date_str, game_rec, feedtype, record)) + get_fetch_filename(date_str, game_rec, feedtype, fetch)) else: LOG.error("No stream URL") else: @@ -185,30 +185,30 @@ def play_stream(game_data, team_to_play, feedtype, date_str, record, login_func) return 0 -def get_recording_filename(date_str, game_rec, feedtype, record): - if record: +def get_fetch_filename(date_str, game_rec, feedtype, fetch): + if fetch: return '{}-{}-{}-{}.mp4'.format(date_str, game_rec['away_abbrev'], game_rec['home_abbrev'], feedtype) else: return None -def play_highlight(playback_url, record_filename): +def play_highlight(playback_url, fetch_filename): video_player = config.CONFIG.parser['video_player'] - if (record_filename is None or record_filename != '') \ + if (fetch_filename is None or fetch_filename != '') \ and not config.CONFIG.parser.getboolean('streamlink_highlights', True): cmd = [video_player, playback_url] LOG.info('Playing highlight: ' + str(cmd)) subprocess.run(cmd) else: - streamlink_highlight(playback_url, record_filename) + streamlink_highlight(playback_url, fetch_filename) -def streamlink_highlight(playback_url, record_filename): +def streamlink_highlight(playback_url, fetch_filename): video_player = config.CONFIG.parser['video_player'] streamlink_cmd = ["streamlink", "--player-no-close", ] - if record_filename is not None: + if fetch_filename is not None: streamlink_cmd.append("--output") - streamlink_cmd.append(record_filename) + streamlink_cmd.append(fetch_filename) elif video_player is not None and video_player != '': LOG.debug('Using video_player: {}'.format(video_player)) streamlink_cmd.append("--player") @@ -225,7 +225,7 @@ def streamlink_highlight(playback_url, record_filename): subprocess.run(streamlink_cmd) -def streamlink(stream_url, media_auth, record_filename=None): +def streamlink(stream_url, media_auth, fetch_filename=None): LOG.info("Stream url: " + stream_url) auth_cookie_str = "Authorization=" + auth.get_auth_cookie() media_auth_cookie_str = media_auth @@ -238,9 +238,9 @@ def streamlink(stream_url, media_auth, record_filename=None): "--http-cookie", auth_cookie_str, "--http-cookie", media_auth_cookie_str, "--http-header", user_agent_hdr] - if record_filename is not None: + if fetch_filename is not None: streamlink_cmd.append("--output") - streamlink_cmd.append(record_filename) + streamlink_cmd.append(fetch_filename) elif video_player is not None and video_player != '': LOG.debug('Using video_player: {}'.format(video_player)) streamlink_cmd.append("--player") diff --git a/nhlv b/nhlv index df25140..9cc91bc 100755 --- a/nhlv +++ b/nhlv @@ -34,22 +34,31 @@ import mlbam.util as util LOG = None # initialized in init_logging +help_header = """NHL game tracker and stream viewer. +""" +help_footer = """See README.md for full usage instructions and pre-requisites. + +Feed Identifiers +You can use either the short form feed identifier or the long form: + + {}""".format(gamedata.get_feedtype_keystring()) + + def main(argv=None): nhl_gamedata = gamedata.NHLGameData() - help_header = """NHL game tracker and stream viewer. - """ - help_footer = """See README.md for full usage instructions and pre-requisites. - """ # using argparse (2.7+) https://docs.python.org/2/library/argparse.html - parser = argparse.ArgumentParser(description=help_header, epilog=help_footer) + parser = argparse.ArgumentParser(description=help_header, epilog=help_footer, + formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("-d", "--date", help="Display games for date. Format: yyyy-mm-dd") parser.add_argument("--days", type=int, default=1, help="Number of days to display") parser.add_argument("--tomorrow", action="store_true", help="Use tomorrow's date") parser.add_argument("--yesterday", action="store_true", help="Use yesterday's date") parser.add_argument("-t", "--team", help="Play game for team, one of: {}".format(nhl_gamedata.TEAM_CODES)) - parser.add_argument("-f", "--feed", help=("Feed type, either a live/archive game feed or highlight feed " - "(if available). Available feeds are shown in game list.")) + parser.add_argument("-f", "--feed", + help=("Feed type, either a live/archive game feed or highlight feed " + "(if available). Available feeds are shown in game list," + "and have a short form and long form (see 'Feed identifiers' section below)")) parser.add_argument("--favs", help=("Favourite teams, a comma-separated list of favourite teams " "(normally specified in config file)")) parser.add_argument("--filter", action="store_true", help="Filter output for favourite teams only") @@ -62,7 +71,7 @@ def main(argv=None): parser.add_argument("--username", help="NHL.tv username. Required for live/archived games.") parser.add_argument("--password", help="NHL.tv password. Required for live/archived games.") parser.add_argument("--use-rogers", help="Use rogers form of NHL.tv authentication") - parser.add_argument("--record", action="store_true", help="Save stream to file instead of playing") + parser.add_argument("--fetch", action="store_true", help="Save stream to file instead of playing") parser.add_argument("-v", "--verbose", action="store_true", help="Increase output verbosity") parser.add_argument("-D", "--debug", action="store_true", help="Turn on debug output") args = parser.parse_args() @@ -123,7 +132,7 @@ def main(argv=None): # nothing to play; we're done return 0 - return common.play_stream(game_data, team_to_play, feedtype, args.date, args.record, auth.nhl_login) + return common.play_stream(game_data, team_to_play, feedtype, args.date, args.fetch, auth.nhl_login) if __name__ == "__main__" or __name__ == "main":