Merge pull request #373 from terminalmage/scores
Add module to display sports scores
This commit is contained in:
commit
b5a4fd2ab0
@ -44,6 +44,15 @@ example configuration for the MaildirMail backend:
|
||||
|
||||
.. nothin'
|
||||
|
||||
.. _scorebackends:
|
||||
|
||||
Score Backends
|
||||
--------------
|
||||
|
||||
.. autogen:: i3pystatus.scores SettingsBase
|
||||
|
||||
.. nothin'
|
||||
|
||||
.. _updatebackends:
|
||||
|
||||
Update Backends
|
||||
|
661
i3pystatus/scores/__init__.py
Normal file
661
i3pystatus/scores/__init__.py
Normal file
@ -0,0 +1,661 @@
|
||||
import copy
|
||||
import json
|
||||
import operator
|
||||
import pytz
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.request import urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
from i3pystatus import SettingsBase, Module, formatp
|
||||
from i3pystatus.core.util import user_open, internet, require
|
||||
|
||||
|
||||
class ScoresBackend(SettingsBase):
|
||||
settings = ()
|
||||
favorite_teams = []
|
||||
all_games = True
|
||||
date = None
|
||||
games = {}
|
||||
scroll_order = []
|
||||
last_update = 0
|
||||
|
||||
def init(self):
|
||||
# Merge the passed team colors with the global ones. A simple length
|
||||
# check is sufficient here because i3pystatus.scores.Scores instance
|
||||
# will already have checked to see if any invalid teams were specified
|
||||
# in team_colors.
|
||||
if len(self.team_colors) != len(self._default_colors):
|
||||
self.logger.debug(
|
||||
'Overriding %s team colors with: %s',
|
||||
self.__class__.__name__,
|
||||
self.team_colors
|
||||
)
|
||||
new_colors = copy.copy(self._default_colors)
|
||||
new_colors.update(self.team_colors)
|
||||
self.team_colors = new_colors
|
||||
self.logger.debug('%s team colors: %s',
|
||||
self.__class__.__name__, self.team_colors)
|
||||
|
||||
def api_request(self, url):
|
||||
self.logger.debug('Making %s API request to %s',
|
||||
self.__class__.__name__, url)
|
||||
try:
|
||||
with urlopen(url) as content:
|
||||
try:
|
||||
content_type = dict(content.getheaders())['Content-Type']
|
||||
charset = re.search(r'charset=(.*)', content_type).group(1)
|
||||
except AttributeError:
|
||||
charset = 'utf-8'
|
||||
response = json.loads(content.read().decode(charset))
|
||||
self.logger.log(5, 'API response: %s', response)
|
||||
return response
|
||||
except HTTPError as exc:
|
||||
self.logger.critical(
|
||||
'Error %s (%s) making request to %s',
|
||||
exc.code, exc.reason, exc.url,
|
||||
)
|
||||
return {}
|
||||
except URLError as exc:
|
||||
self.logger.critical('Error making request to %s: %s', url, exc)
|
||||
return {}
|
||||
|
||||
def get_api_date(self):
|
||||
'''
|
||||
Figure out the date to use for API requests. Assumes yesterday's date
|
||||
if between midnight and 10am Eastern time. Override this function in a
|
||||
subclass to change how the API date is calculated.
|
||||
'''
|
||||
# NOTE: If you are writing your own function to get the date, make sure
|
||||
# to include the first if block below to allow for the ``date``
|
||||
# parameter to hard-code a date.
|
||||
api_date = None
|
||||
if self.date is not None and not isinstance(self.date, datetime):
|
||||
try:
|
||||
api_date = datetime.strptime(self.date, '%Y-%m-%d')
|
||||
except (TypeError, ValueError):
|
||||
self.logger.warning('Invalid date \'%s\'', self.date)
|
||||
|
||||
if api_date is None:
|
||||
utc_time = pytz.utc.localize(datetime.utcnow())
|
||||
eastern = pytz.timezone('US/Eastern')
|
||||
api_date = eastern.normalize(utc_time.astimezone(eastern))
|
||||
if api_date.hour < 10:
|
||||
# The scores on NHL.com change at 10am Eastern, if it's before
|
||||
# that time of day then we will use yesterday's date.
|
||||
api_date -= timedelta(days=1)
|
||||
self.date = api_date
|
||||
|
||||
@staticmethod
|
||||
def add_ordinal(number):
|
||||
try:
|
||||
number = int(number)
|
||||
except ValueError:
|
||||
return number
|
||||
if 4 <= number <= 20:
|
||||
return '%d%s' % (number, 'th')
|
||||
else:
|
||||
ord_map = {1: 'st', 2: 'nd', 3: 'rd'}
|
||||
return '%d%s' % (number, ord_map.get(number % 10, 'th'))
|
||||
|
||||
@staticmethod
|
||||
def force_int(value):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def get_nested(self, data, expr, callback=None, default=None):
|
||||
if callback is None:
|
||||
def callback(x):
|
||||
return x
|
||||
try:
|
||||
for key in expr.split(':'):
|
||||
if key.isdigit() and isinstance(data, list):
|
||||
key = int(key)
|
||||
data = data[key]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
self.logger.debug('No %s data found at %s, falling back to %s',
|
||||
self.__class__.__name__, expr, repr(default))
|
||||
return default
|
||||
return callback(data)
|
||||
|
||||
def interpret_api_return(self, data, team_game_map):
|
||||
favorite_games = []
|
||||
# Cycle through the followed teams to ensure that games show up in the
|
||||
# order of teams being followed.
|
||||
for team in self.favorite_teams:
|
||||
for id_ in team_game_map.get(team, []):
|
||||
if id_ not in favorite_games:
|
||||
favorite_games.append(id_)
|
||||
|
||||
# If all games are being tracked, add any games not from
|
||||
# explicitly-followed teams.
|
||||
if self.all_games:
|
||||
additional_games = [x for x in data if x not in favorite_games]
|
||||
else:
|
||||
additional_games = []
|
||||
|
||||
# Process the API return data for each tracked game
|
||||
self.games = {}
|
||||
for game_id in favorite_games + additional_games:
|
||||
self.games[game_id] = self.process_game(data[game_id])
|
||||
|
||||
# Favorite games come first
|
||||
self.scroll_order = [self.games[x]['id'] for x in favorite_games]
|
||||
|
||||
# For any remaining games being tracked, sort each group by start time
|
||||
# and add them to the list
|
||||
for status in self.display_order:
|
||||
time_map = {
|
||||
x: self.games[x]['start_time'] for x in self.games
|
||||
if x not in favorite_games and self.games[x]['status'] == status
|
||||
}
|
||||
sorted_games = sorted(time_map.items(), key=operator.itemgetter(1))
|
||||
self.scroll_order.extend([x[0] for x in sorted_games])
|
||||
|
||||
# Reverse map so that we can know the scroll position for a given game
|
||||
# by just its ID. This will help us to place the game in its new order
|
||||
# when that order changes due to the game changing from one status to
|
||||
# another.
|
||||
self.scroll_order_revmap = {y: x for x, y in enumerate(self.scroll_order)}
|
||||
|
||||
|
||||
class Scores(Module):
|
||||
'''
|
||||
This is a generic score checker, which must use at least one configured
|
||||
:ref:`score backend <scorebackends>`.
|
||||
|
||||
Followed games can be scrolled through with the mouse/trackpad.
|
||||
Left-clicking on the module will refresh the scores, while right-clicking
|
||||
it will cycle through the configured backends. Double-clicking the module
|
||||
with the left button will launch the league-specific (MLB Gameday / NHL
|
||||
GameCenter / etc.) URL for the game. If there is not an active game,
|
||||
double-clicking will launch the league-specific scoreboard URL containing
|
||||
all games for the current day.
|
||||
|
||||
Double-clicking with the right button will reset the current backend to the
|
||||
first game in the scroll list. This is useful for quickly switching back to
|
||||
a followed team's game after looking at other game scores.
|
||||
|
||||
Scores for the previous day's games will be shown until 10am Eastern Time
|
||||
(US), after which time the current day's games will be shown.
|
||||
|
||||
.. rubric:: Available formatters
|
||||
|
||||
Formatters are set in the backend instances, see the :ref:`scorebackends`
|
||||
for more information.
|
||||
|
||||
This module supports the :ref:`formatp <formatp>` extended string format
|
||||
syntax. This allows for values to be hidden when they evaluate as False
|
||||
(e.g. when a formatter is blank (an empty string). The default values for
|
||||
the format strings set in the :ref:`score backends <scorebackends>`
|
||||
(``format_pregame``, ``format_in_progress``, etc.) make heavy use of
|
||||
formatp, hiding many formatters when they are blank.
|
||||
|
||||
.. rubric:: Usage example
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from i3pystatus import Status
|
||||
from i3pystatus.scores import mlb, nhl
|
||||
|
||||
status = Status()
|
||||
|
||||
status.register(
|
||||
'scores',
|
||||
hints={'markup': 'pango'},
|
||||
colorize_teams=True,
|
||||
favorite_icon='<span size="small" color="#F5FF00">★</span>',
|
||||
backends=[
|
||||
mlb.MLB(
|
||||
teams=['CWS', 'SF'],
|
||||
format_no_games='No games today :(',
|
||||
inning_top='⬆',
|
||||
inning_bottom='⬇',
|
||||
),
|
||||
nhl.NHL(teams=['CHI']),
|
||||
nba.NBA(
|
||||
teams=['GSW'],
|
||||
all_games=False,
|
||||
),
|
||||
epl.EPL(),
|
||||
],
|
||||
)
|
||||
|
||||
status.run()
|
||||
|
||||
To enable colorized team name/city/abbbreviation, ``colorize_teams`` must
|
||||
be set to ``True``. This also requires that i3bar is configured to use
|
||||
Pango, and that the :ref:`hints <hints>` param is set for the module and
|
||||
includes a ``markup`` key, as in the example above. To ensure that i3bar is
|
||||
configured to use Pango, the `font param`__ in your i3 config file must
|
||||
start with ``pango:``.
|
||||
|
||||
.. __: http://i3wm.org/docs/userguide.html#fonts
|
||||
|
||||
.. _scores-game-order:
|
||||
|
||||
If a ``teams`` param is not specified for the backend, then all games for
|
||||
the current day will be tracked, and will be ordered by the start time of
|
||||
the game. Otherwise, only games from explicitly-followed teams will be
|
||||
tracked, and will be in the same order as listed. If ``ALL`` is part of the
|
||||
list, then games from followed teams will be first in the scroll list,
|
||||
followed by all remaining games in order of start time.
|
||||
|
||||
Therefore, in the above example, only White Sox and Giants games would be
|
||||
tracked, while in the below example all games would be tracked, with
|
||||
White Sox and Giants games appearing first in the scroll list and the
|
||||
remaining games appearing after them, in order of start time.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from i3pystatus import Status
|
||||
from i3pystatus.scores import mlb
|
||||
|
||||
status = Status()
|
||||
|
||||
status.register(
|
||||
'scores',
|
||||
hints={'markup': 'pango'},
|
||||
colorize_teams=True,
|
||||
favorite_icon='<span size="small" color="#F5FF00">★</span>',
|
||||
backends=[
|
||||
mlb.MLB(
|
||||
teams=['CWS', 'SF', 'ALL'],
|
||||
team_colors={
|
||||
'NYM': '#1D78CA',
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
status.run()
|
||||
|
||||
.. rubric:: Troubleshooting
|
||||
|
||||
If the module gets stuck during an update (i.e. the ``refresh_icon`` does
|
||||
not go away), then the update thread probably encountered a traceback. This
|
||||
traceback will (by default) be logged to ``~/.i3pystatus-<pid>`` where
|
||||
``<pid>`` is the PID of the thread. However, it may be more convenient to
|
||||
manually set the logfile to make the location of the log data reliable and
|
||||
avoid clutter in your home directory. For example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import logging
|
||||
from i3pystatus import Status
|
||||
from i3pystatus.scores import mlb, nhl
|
||||
|
||||
status = Status(
|
||||
logfile='/home/username/var/i3pystatus.log',
|
||||
)
|
||||
|
||||
status.register(
|
||||
'scores',
|
||||
log_level=logging.DEBUG,
|
||||
backends=[
|
||||
mlb.MLB(
|
||||
teams=['CWS', 'SF'],
|
||||
log_level=logging.DEBUG,
|
||||
),
|
||||
nhl.NHL(
|
||||
teams=['CHI'],
|
||||
log_level=logging.DEBUG,
|
||||
),
|
||||
nba.NBA(
|
||||
teams=['CHI'],
|
||||
log_level=logging.DEBUG,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
status.run()
|
||||
|
||||
.. note::
|
||||
The ``log_level`` must be set separately in both the module and the
|
||||
backend instances (as shown above), otherwise the backends will
|
||||
still use the default log level.
|
||||
'''
|
||||
interval = 300
|
||||
|
||||
settings = (
|
||||
('backends', 'List of backend instances'),
|
||||
('favorite_icon', 'Value for the ``{away_favorite}`` and '
|
||||
'``{home_favorite}`` formatter when the displayed game '
|
||||
'is being played by a followed team'),
|
||||
('color', 'Color to be used for non-colorized text (defaults to the '
|
||||
'i3bar color)'),
|
||||
('color_no_games', 'Color to use when no games are scheduled for the '
|
||||
'currently-displayed backend (defaults to the '
|
||||
'i3bar color)'),
|
||||
('colorize_teams', 'Dislay team city, name, and abbreviation in the '
|
||||
'team\'s color (as defined in the '
|
||||
':ref:`backend <scorebackends>`\'s ``team_colors`` '
|
||||
'attribute)'),
|
||||
('scroll_arrow', 'Value used for the ``{scroll}`` formatter to '
|
||||
'indicate that more than one game is being tracked '
|
||||
'for the currently-displayed backend'),
|
||||
('refresh_icon', 'Text to display (in addition to any text currently '
|
||||
'shown by the module) when refreshing scores. '
|
||||
'**NOTE:** Depending on how quickly the update is '
|
||||
'performed, the icon may not be displayed.'),
|
||||
)
|
||||
|
||||
backends = []
|
||||
favorite_icon = '★'
|
||||
color = None
|
||||
color_no_games = None
|
||||
colorize_teams = False
|
||||
scroll_arrow = '⬍'
|
||||
refresh_icon = '⟳'
|
||||
|
||||
output = {'full_text': ''}
|
||||
game_map = {}
|
||||
backend_id = 0
|
||||
|
||||
on_upscroll = ['scroll_game', 1]
|
||||
on_downscroll = ['scroll_game', -1]
|
||||
on_leftclick = ['check_scores', 'click event']
|
||||
on_rightclick = ['cycle_backend', 1]
|
||||
on_doubleleftclick = ['launch_web']
|
||||
on_doublerightclick = ['reset_backend']
|
||||
|
||||
def init(self):
|
||||
if not isinstance(self.backends, list):
|
||||
self.backends = [self.backends]
|
||||
|
||||
if not self.backends:
|
||||
raise ValueError('At least one backend is required')
|
||||
|
||||
# Initialize each backend's game index
|
||||
for index in range(len(self.backends)):
|
||||
self.game_map[index] = None
|
||||
|
||||
for backend in self.backends:
|
||||
if hasattr(backend, '_valid_teams'):
|
||||
for index in range(len(backend.favorite_teams)):
|
||||
# Force team abbreviation to uppercase
|
||||
team_uc = str(backend.favorite_teams[index]).upper()
|
||||
# Check to make sure the team abbreviation is valid
|
||||
if team_uc not in backend._valid_teams:
|
||||
raise ValueError(
|
||||
'Invalid %s team \'%s\'' % (
|
||||
backend.__class__.__name__,
|
||||
backend.favorite_teams[index]
|
||||
)
|
||||
)
|
||||
backend.favorite_teams[index] = team_uc
|
||||
|
||||
for index in range(len(backend.display_order)):
|
||||
order_lc = str(backend.display_order[index]).lower()
|
||||
# Check to make sure the display order item is valid
|
||||
if order_lc not in backend._valid_display_order:
|
||||
raise ValueError(
|
||||
'Invalid %s display_order \'%s\'' % (
|
||||
backend.__class__.__name__,
|
||||
backend.display_order[index]
|
||||
)
|
||||
)
|
||||
backend.display_order[index] = order_lc
|
||||
|
||||
self.condition = threading.Condition()
|
||||
self.thread = threading.Thread(target=self.update_thread, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def update_thread(self):
|
||||
try:
|
||||
self.check_scores(force='scheduled')
|
||||
while True:
|
||||
with self.condition:
|
||||
self.condition.wait(self.interval)
|
||||
self.check_scores(force='scheduled')
|
||||
except:
|
||||
msg = 'Exception in {thread} at {time}, module {name}'.format(
|
||||
thread=threading.current_thread().name,
|
||||
time=time.strftime('%c'),
|
||||
name=self.__class__.__name__,
|
||||
)
|
||||
self.logger.error(msg, exc_info=True)
|
||||
|
||||
@property
|
||||
def current_backend(self):
|
||||
return self.backends[self.backend_id]
|
||||
|
||||
@property
|
||||
def current_scroll_index(self):
|
||||
return self.game_map[self.backend_id]
|
||||
|
||||
@property
|
||||
def current_game_id(self):
|
||||
try:
|
||||
return self.current_backend.scroll_order[self.current_scroll_index]
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_game(self):
|
||||
try:
|
||||
return self.current_backend.games[self.current_game_id]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def scroll_game(self, step=1):
|
||||
cur_index = self.current_scroll_index
|
||||
if cur_index is None:
|
||||
self.logger.debug(
|
||||
'Cannot scroll, no tracked {backend} games for '
|
||||
'{date:%Y-%m-%d}'.format(
|
||||
backend=self.current_backend.__class__.__name__,
|
||||
date=self.current_backend.date,
|
||||
)
|
||||
)
|
||||
else:
|
||||
new_index = (cur_index + step) % len(self.current_backend.scroll_order)
|
||||
if new_index != cur_index:
|
||||
cur_id = self.current_game_id
|
||||
# Don't reference self.current_scroll_index here, we're setting
|
||||
# a new value for the data point for which
|
||||
# self.current_scroll_index serves as a shorthand.
|
||||
self.game_map[self.backend_id] = new_index
|
||||
self.logger.debug(
|
||||
'Scrolled from %s game %d (ID: %s) to %d (ID: %s)',
|
||||
self.current_backend.__class__.__name__,
|
||||
cur_index,
|
||||
cur_id,
|
||||
new_index,
|
||||
self.current_backend.scroll_order[new_index],
|
||||
)
|
||||
self.refresh_display()
|
||||
else:
|
||||
self.logger.debug(
|
||||
'Cannot scroll, only one tracked {backend} game '
|
||||
'(ID: {id_}) for {date:%Y-%m-%d}'.format(
|
||||
backend=self.current_backend.__class__.__name__,
|
||||
id_=self.current_game_id,
|
||||
date=self.current_backend.date,
|
||||
)
|
||||
)
|
||||
|
||||
def cycle_backend(self, step=1):
|
||||
if len(self.backends) < 2:
|
||||
self.logger.debug(
|
||||
'Only one backend (%s) configured, backend cannot be changed',
|
||||
self.current_backend.__class__.__name__,
|
||||
)
|
||||
return
|
||||
old = self.backend_id
|
||||
# Set the new backend
|
||||
self.backend_id = (self.backend_id + step) % len(self.backends)
|
||||
self.logger.debug(
|
||||
'Changed scores backend from %s to %s',
|
||||
self.backends[old].__class__.__name__,
|
||||
self.current_backend.__class__.__name__,
|
||||
)
|
||||
# Display the score for the new backend. This gets rid of lag between
|
||||
# when the mouse is clicked and when the new backend is shown, caused
|
||||
# by any network latency encountered when updating scores.
|
||||
self.refresh_display()
|
||||
# Update scores (if necessary) and display them
|
||||
self.check_scores()
|
||||
|
||||
def reset_backend(self):
|
||||
if self.current_backend.games:
|
||||
self.game_map[self.backend_id] = 0
|
||||
self.logger.debug(
|
||||
'Resetting to first game in %s scroll list (ID: %s)',
|
||||
self.current_backend.__class__.__name__,
|
||||
self.current_game_id,
|
||||
)
|
||||
self.refresh_display()
|
||||
else:
|
||||
self.logger.debug(
|
||||
'No %s games, cannot reset to first game in scroll list',
|
||||
self.current_backend.__class__.__name__,
|
||||
)
|
||||
|
||||
def launch_web(self):
|
||||
game = self.current_game
|
||||
if game is None:
|
||||
live_url = self.current_backend.scoreboard_url
|
||||
else:
|
||||
live_url = game['live_url']
|
||||
self.logger.debug('Launching %s in browser', live_url)
|
||||
user_open(live_url)
|
||||
|
||||
@require(internet)
|
||||
def check_scores(self, force=False):
|
||||
update_needed = False
|
||||
if not self.current_backend.last_update:
|
||||
update_needed = True
|
||||
self.logger.debug(
|
||||
'Performing initial %s score check',
|
||||
self.current_backend.__class__.__name__,
|
||||
)
|
||||
elif force:
|
||||
update_needed = True
|
||||
self.logger.debug(
|
||||
'%s score check triggered (%s)',
|
||||
self.current_backend.__class__.__name__,
|
||||
force
|
||||
)
|
||||
else:
|
||||
update_diff = time.time() - self.current_backend.last_update
|
||||
msg = ('Seconds since last %s update (%f) ' %
|
||||
(self.current_backend.__class__.__name__, update_diff))
|
||||
if update_diff >= self.interval:
|
||||
update_needed = True
|
||||
msg += ('meets or exceeds update interval (%d), update '
|
||||
'triggered' % self.interval)
|
||||
else:
|
||||
msg += ('does not exceed update interval (%d), update '
|
||||
'skipped' % self.interval)
|
||||
self.logger.debug(msg)
|
||||
|
||||
if update_needed:
|
||||
self.show_refresh_icon()
|
||||
cur_id = self.current_game_id
|
||||
cur_games = self.current_backend.games.keys()
|
||||
self.current_backend.check_scores()
|
||||
if cur_games == self.current_backend.games.keys():
|
||||
# Set the index to the scroll position of the current game (it
|
||||
# may have changed due to this game or other games changing
|
||||
# status.
|
||||
if cur_id is None:
|
||||
self.logger.debug(
|
||||
'No tracked {backend} games for {date:%Y-%m-%d}'.format(
|
||||
backend=self.current_backend.__class__.__name__,
|
||||
date=self.current_backend.date,
|
||||
)
|
||||
)
|
||||
else:
|
||||
cur_pos = self.game_map[self.backend_id]
|
||||
new_pos = self.current_backend.scroll_order_revmap[cur_id]
|
||||
if cur_pos != new_pos:
|
||||
self.game_map[self.backend_id] = new_pos
|
||||
self.logger.debug(
|
||||
'Scroll position for current %s game (%s) updated '
|
||||
'from %d to %d',
|
||||
self.current_backend.__class__.__name__,
|
||||
cur_id,
|
||||
cur_pos,
|
||||
new_pos,
|
||||
)
|
||||
else:
|
||||
self.logger.debug(
|
||||
'Scroll position (%d) for current %s game (ID: %s) '
|
||||
'unchanged',
|
||||
cur_pos,
|
||||
self.current_backend.__class__.__name__,
|
||||
cur_id,
|
||||
)
|
||||
else:
|
||||
# Reset the index to 0 if there are any tracked games,
|
||||
# otherwise set it to None to signify no tracked games for the
|
||||
# backend.
|
||||
if self.current_backend.games:
|
||||
self.game_map[self.backend_id] = 0
|
||||
self.logger.debug(
|
||||
'Tracked %s games updated, setting scroll position to '
|
||||
'0 (ID: %s)',
|
||||
self.current_backend.__class__.__name__,
|
||||
self.current_game_id
|
||||
)
|
||||
else:
|
||||
self.game_map[self.backend_id] = None
|
||||
self.logger.debug(
|
||||
'No tracked {backend} games for {date:%Y-%m-%d}'.format(
|
||||
backend=self.current_backend.__class__.__name__,
|
||||
date=self.current_backend.date,
|
||||
)
|
||||
)
|
||||
self.current_backend.last_update = time.time()
|
||||
self.refresh_display()
|
||||
|
||||
def show_refresh_icon(self):
|
||||
self.output['full_text'] = \
|
||||
self.refresh_icon + self.output.get('full_text', '')
|
||||
|
||||
def refresh_display(self):
|
||||
if self.current_scroll_index is None:
|
||||
output = self.current_backend.format_no_games
|
||||
color = self.color_no_games
|
||||
else:
|
||||
game = copy.copy(self.current_game)
|
||||
|
||||
fstr = str(getattr(
|
||||
self.current_backend,
|
||||
'format_%s' % game['status']
|
||||
))
|
||||
|
||||
for team in ('home', 'away'):
|
||||
abbrev_key = '%s_abbrev' % team
|
||||
# Set favorite icon, if applicable
|
||||
game['%s_favorite' % team] = self.favorite_icon \
|
||||
if game[abbrev_key] in self.current_backend.favorite_teams \
|
||||
else ''
|
||||
|
||||
if self.colorize_teams:
|
||||
# Wrap in Pango markup
|
||||
color = self.current_backend.team_colors.get(
|
||||
game.get(abbrev_key)
|
||||
)
|
||||
if color is not None:
|
||||
for item in ('abbrev', 'city', 'name', 'name_short'):
|
||||
key = '%s_%s' % (team, item)
|
||||
if key in game:
|
||||
val = '<span color="%s">%s</span>' % (color, game[key])
|
||||
game[key] = val
|
||||
|
||||
game['scroll'] = self.scroll_arrow \
|
||||
if len(self.current_backend.games) > 1 \
|
||||
else ''
|
||||
|
||||
output = formatp(fstr, **game).strip()
|
||||
|
||||
self.output = {'full_text': output, 'color': self.color}
|
||||
|
||||
def run(self):
|
||||
pass
|
373
i3pystatus/scores/epl.py
Normal file
373
i3pystatus/scores/epl.py
Normal file
@ -0,0 +1,373 @@
|
||||
from i3pystatus.core.util import internet, require
|
||||
from i3pystatus.scores import ScoresBackend
|
||||
|
||||
import copy
|
||||
import pytz
|
||||
import time
|
||||
from collections import namedtuple
|
||||
from datetime import datetime
|
||||
|
||||
LIVE_URL = 'http://live.premierleague.com/#/gameweek/%s/matchday/%s/match/%s'
|
||||
CONTEXT_URL = 'http://live.premierleague.com/syndicationdata/context.json'
|
||||
SCOREBOARD_URL = 'http://live.premierleague.com/'
|
||||
API_URL = 'http://live.premierleague.com/syndicationdata/competitionId=%s/seasonId=%s/gameWeekId=%s/scores.json'
|
||||
STATS_URL = 'http://live.premierleague.com/syndicationdata/competitionId=%s/seasonId=%s/matchDayId=%s/league-table.json'
|
||||
MATCH_DETAILS_URL = 'http://live.premierleague.com/syndicationdata/competitionId=%s/seasonId=%s/matchDayId=%s/matchId=%s/match-details.json'
|
||||
|
||||
MATCH_STATUS_PREGAME = 1
|
||||
MATCH_STATUS_IN_PROGRESS = 2
|
||||
MATCH_STATUS_FINAL = 3
|
||||
MATCH_STATUS_HALFTIME = 4
|
||||
|
||||
|
||||
class EPL(ScoresBackend):
|
||||
'''
|
||||
Backend to retrieve scores from the English Premier League. For usage
|
||||
examples, see :py:mod:`here <.scores>`.
|
||||
|
||||
.. rubric:: Promotion / Relegation
|
||||
|
||||
Due to promotion/relegation, the **team_colors** configuration will
|
||||
eventuall become out of date. When this happens, it will be necessary to
|
||||
manually set the colors for the newly-promoted teams until the source for
|
||||
this module is updated. An example of setting colors for newly promoted
|
||||
teams can be seen below:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from i3pystatus import Status
|
||||
from i3pystatus.scores import epl
|
||||
|
||||
status = Status()
|
||||
|
||||
status.register(
|
||||
'scores',
|
||||
hints={'markup': 'pango'},
|
||||
colorize_teams=True,
|
||||
backends=[
|
||||
epl.EPL(
|
||||
teams=['LIV'],
|
||||
team_colors={
|
||||
'ABC': '#1D78CA',
|
||||
'DEF': '#8AFEC3',
|
||||
'GHI': '#33FA6D',
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
status.run()
|
||||
|
||||
.. rubric:: Available formatters
|
||||
|
||||
* `{home_name}` — Name of home team (e.g. **Tottenham Hotspur**)
|
||||
* `{home_name_short}` — Shortened team name (e.g. **Spurs**)
|
||||
* `{home_abbrev}` — 2 or 3-letter abbreviation for home team's city (e.g.
|
||||
**TOT**)
|
||||
* `{home_score}` — Home team's current score
|
||||
* `{home_wins}` — Home team's number of wins
|
||||
* `{home_losses}` — Home team's number of losses
|
||||
* `{home_draws}` — Home team's number of draws
|
||||
* `{home_points}` — Home team's number of standings points
|
||||
* `{home_favorite}` — Displays the value for the :py:mod:`.scores` module's
|
||||
``favorite`` attribute, if the home team is one of the teams being
|
||||
followed. Otherwise, this formatter will be blank.
|
||||
* `{away_name}` — Name of away team (e.g. **Manchester United**)
|
||||
* `{away_name_short}` — Name of away team's city (e.g. **Man Utd**)
|
||||
* `{away_abbrev}` — 2 or 3-letter abbreviation for away team's name (e.g.
|
||||
**MUN**)
|
||||
* `{away_score}` — Away team's current score
|
||||
* `{away_wins}` — Away team's number of wins
|
||||
* `{away_losses}` — Away team's number of losses
|
||||
* `{away_draws}` — Away team's number of draws
|
||||
* `{away_points}` — Away team's number of standings points
|
||||
* `{away_favorite}` — Displays the value for the :py:mod:`.scores` module's
|
||||
``favorite`` attribute, if the away team is one of the teams being
|
||||
followed. Otherwise, this formatter will be blank.
|
||||
* `{minute}` — Current minute of game when in progress
|
||||
* `{start_time}` — Start time of game in system's localtime (supports
|
||||
strftime formatting, e.g. `{start_time:%I:%M %p}`)
|
||||
|
||||
.. rubric:: Team abbreviations
|
||||
|
||||
* **ARS** — Arsenal
|
||||
* **AVL** — Aston Villa
|
||||
* **BOU** — Bournemouth
|
||||
* **CHE** — Chelsea
|
||||
* **CRY** — Crystal Palace
|
||||
* **EVE** — Everton
|
||||
* **LEI** — Leicester City
|
||||
* **LIV** — Liverpool
|
||||
* **MCI** — Manchester City
|
||||
* **MUN** — Manchester United
|
||||
* **NEW** — Newcastle United
|
||||
* **NOR** — Norwich City
|
||||
* **SOU** — Southampton
|
||||
* **STK** — Stoke City
|
||||
* **SUN** — Sunderland Association
|
||||
* **SWA** — Swansea City
|
||||
* **TOT** — Tottenham Hotspur
|
||||
* **WAT** — Watford
|
||||
* **WBA** — West Bromwich Albion
|
||||
* **WHU** — West Ham United
|
||||
'''
|
||||
interval = 300
|
||||
|
||||
settings = (
|
||||
('favorite_teams', 'List of abbreviations of favorite teams. Games '
|
||||
'for these teams will appear first in the scroll '
|
||||
'list. A detailed description of how games are '
|
||||
'ordered can be found '
|
||||
':ref:`here <scores-game-order>`.'),
|
||||
('all_games', 'If set to ``True``, all games will be present in '
|
||||
'the scroll list. If set to ``False``, then only '
|
||||
'games from **favorite_teams** will be present in '
|
||||
'the scroll list.'),
|
||||
('display_order', 'When **all_games** is set to ``True``, this '
|
||||
'option will dictate the order in which games from '
|
||||
'teams not in **favorite_teams** are displayed'),
|
||||
('format_no_games', 'Format used when no tracked games are scheduled '
|
||||
'for the current day (does not support formatter '
|
||||
'placeholders)'),
|
||||
('format_pregame', 'Format used when the game has not yet started'),
|
||||
('format_in_progress', 'Format used when the game is in progress'),
|
||||
('format_final', 'Format used when the game is complete'),
|
||||
('team_colors', 'Dictionary mapping team abbreviations to hex color '
|
||||
'codes. If overridden, the passed values will be '
|
||||
'merged with the defaults, so it is not necessary to '
|
||||
'define all teams if specifying this value.'),
|
||||
('date', 'Date for which to display game scores, in **YYYY-MM-DD** '
|
||||
'format. If unspecified, the date will be determined by '
|
||||
'the return value of an API call to the **context_url**. '
|
||||
'Due to API limitations, the date can presently only be '
|
||||
'overridden to another date in the current week. This '
|
||||
'option exists primarily for troubleshooting purposes.'),
|
||||
('live_url', 'URL string to launch EPL Live Match Centre. This value '
|
||||
'should not need to be changed.'),
|
||||
('scoreboard_url', 'Link to the EPL scoreboard page. Like '
|
||||
'**live_url**, this value should not need to be '
|
||||
'changed.'),
|
||||
('api_url', 'Alternate URL string from which to retrieve score data. '
|
||||
'Like **live_url**, this value should not need to be '
|
||||
'changed.'),
|
||||
('stats_url', 'Alternate URL string from which to retrieve team '
|
||||
'statistics. Like **live_url**, this value should not '
|
||||
'need to be changed.'),
|
||||
('match_details_url', 'Alternate URL string from which to retrieve '
|
||||
'match details. Like **live_url**, this value '
|
||||
'should not need to be changed.'),
|
||||
)
|
||||
|
||||
required = ()
|
||||
|
||||
_default_colors = {
|
||||
'ARS': '#ED1B22',
|
||||
'AVL': '#94BEE5',
|
||||
'BOU': '#CB0B0F',
|
||||
'CHE': '#195FAF',
|
||||
'CRY': '#195FAF',
|
||||
'EVE': '#004F9E',
|
||||
'LEI': '#304FB6',
|
||||
'LIV': '#D72129',
|
||||
'MCI': '#74B2E0',
|
||||
'MUN': '#DD1921',
|
||||
'NEW': '#06B3EB',
|
||||
'NOR': '#00A651',
|
||||
'SOU': '#DB1C26',
|
||||
'STK': '#D81732',
|
||||
'SUN': '#BC0007',
|
||||
'SWA': '#B28250',
|
||||
'TOT': '#DADADA',
|
||||
'WAT': '#E4D500',
|
||||
'WBA': '#B43C51',
|
||||
'WHU': '#9DE4FA',
|
||||
}
|
||||
|
||||
_valid_display_order = ['in_progress', 'final', 'pregame']
|
||||
|
||||
display_order = _valid_display_order
|
||||
format_no_games = 'EPL: No games'
|
||||
format_pregame = '[{scroll} ]EPL: [{away_favorite} ]{away_abbrev} ({away_points}, {away_wins}-{away_losses}-{away_draws}) at [{home_favorite} ]{home_abbrev} ({home_points}, {home_wins}-{home_losses}-{home_draws}) {start_time:%H:%M %Z}'
|
||||
format_in_progress = '[{scroll} ]EPL: [{away_favorite} ]{away_abbrev} {away_score}[ ({away_power_play})], [{home_favorite} ]{home_abbrev} {home_score}[ ({home_power_play})] ({minute})'
|
||||
format_final = '[{scroll} ]EPL: [{away_favorite} ]{away_abbrev} {away_score} ({away_points}, {away_wins}-{away_losses}-{away_draws}) at [{home_favorite} ]{home_abbrev} {home_score} ({home_points}, {home_wins}-{home_losses}-{home_draws}) (Final)'
|
||||
team_colors = _default_colors
|
||||
context_url = CONTEXT_URL
|
||||
live_url = LIVE_URL
|
||||
scoreboard_url = SCOREBOARD_URL
|
||||
api_url = API_URL
|
||||
stats_url = STATS_URL
|
||||
match_details_url = MATCH_DETAILS_URL
|
||||
|
||||
def get_api_date(self):
|
||||
# NOTE: We're not really using this date for EPL API calls, but we do
|
||||
# need it to allow for a 'date' param to override which date we use for
|
||||
# scores.
|
||||
if self.date is not None and not isinstance(self.date, datetime):
|
||||
try:
|
||||
self.date = datetime.strptime(self.date, '%Y-%m-%d')
|
||||
except (TypeError, ValueError):
|
||||
self.logger.warning('Invalid date \'%s\'', self.date)
|
||||
|
||||
if self.date is None:
|
||||
self.date = datetime.strptime(self.context.date, '%Y%m%d')
|
||||
|
||||
def get_context(self):
|
||||
response = self.api_request(self.context_url)
|
||||
context_tuple = namedtuple(
|
||||
'Context',
|
||||
('competition', 'date', 'game_week', 'match_day', 'season')
|
||||
)
|
||||
self.context = context_tuple(
|
||||
*[
|
||||
response.get(x, '')
|
||||
for x in ('competitionId', 'currentDay', 'gameWeekId',
|
||||
'matchDayId', 'seasonId')
|
||||
]
|
||||
)
|
||||
|
||||
def get_team_stats(self):
|
||||
ret = {}
|
||||
url = self.stats_url % (self.context.competition,
|
||||
self.context.season,
|
||||
self.context.match_day)
|
||||
for item in self.api_request(url).get('Data', []):
|
||||
try:
|
||||
key = item.pop('TeamCode')
|
||||
except KeyError:
|
||||
self.logger.debug('Error occurred obtaining %s team stats',
|
||||
self.__class__.__name__,
|
||||
exc_info=True)
|
||||
continue
|
||||
ret[key] = item
|
||||
return ret
|
||||
|
||||
def get_minute(self, data, id_):
|
||||
match_status = data[id_].get('StatusId', MATCH_STATUS_PREGAME)
|
||||
if match_status == MATCH_STATUS_HALFTIME:
|
||||
return 'Halftime'
|
||||
if match_status == MATCH_STATUS_IN_PROGRESS:
|
||||
url = self.match_details_url % (self.context.competition,
|
||||
self.context.season,
|
||||
data[id_].get('MatchDayId', ''),
|
||||
id_)
|
||||
try:
|
||||
response = self.api_request(url)
|
||||
return '%s\'' % response['Data']['Minute']
|
||||
except (KeyError, TypeError):
|
||||
return '?\''
|
||||
else:
|
||||
return '?\''
|
||||
|
||||
def check_scores(self):
|
||||
self.get_context()
|
||||
self.get_api_date()
|
||||
|
||||
url = self.api_url % (self.context.competition,
|
||||
self.context.season,
|
||||
self.context.game_week)
|
||||
|
||||
for item in self.api_request(url).get('Data', []):
|
||||
if item.get('Key', '') == self.date.strftime('%Y%m%d'):
|
||||
game_list = item.get('Scores', [])
|
||||
break
|
||||
else:
|
||||
game_list = []
|
||||
self.logger.debug('game_list = %s', game_list)
|
||||
|
||||
team_stats = self.get_team_stats()
|
||||
|
||||
# Convert list of games to dictionary for easy reference later on
|
||||
data = {}
|
||||
team_game_map = {}
|
||||
for game in game_list:
|
||||
try:
|
||||
id_ = game['Id']
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
try:
|
||||
for key in ('HomeTeam', 'AwayTeam'):
|
||||
team = game[key]['Code'].upper()
|
||||
if team in self.favorite_teams:
|
||||
team_game_map.setdefault(team, []).append(id_)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
data[id_] = game
|
||||
# Merge in the team stats, because they are not returned in the
|
||||
# initial API request.
|
||||
for key in ('HomeTeam', 'AwayTeam'):
|
||||
team = game[key]['Code'].upper()
|
||||
data[id_][key]['Stats'] = team_stats.get(team, {})
|
||||
# Add the minute, if applicable
|
||||
data[id_]['Minute'] = self.get_minute(data, id_)
|
||||
|
||||
self.interpret_api_return(data, team_game_map)
|
||||
|
||||
def process_game(self, game):
|
||||
ret = {}
|
||||
|
||||
def _update(ret_key, game_key=None, callback=None, default='?'):
|
||||
ret[ret_key] = self.get_nested(game,
|
||||
game_key or ret_key,
|
||||
callback=callback,
|
||||
default=default)
|
||||
|
||||
self.logger.debug('Processing %s game data: %s',
|
||||
self.__class__.__name__, game)
|
||||
|
||||
_update('id', 'Id')
|
||||
_update('minute', 'Minute')
|
||||
ret['live_url'] = self.live_url % (self.context.game_week,
|
||||
self.context.match_day,
|
||||
ret['id'])
|
||||
|
||||
status_map = {
|
||||
MATCH_STATUS_PREGAME: 'pregame',
|
||||
MATCH_STATUS_IN_PROGRESS: 'in_progress',
|
||||
MATCH_STATUS_FINAL: 'final',
|
||||
MATCH_STATUS_HALFTIME: 'in_progress',
|
||||
}
|
||||
status_code = game.get('StatusId')
|
||||
if status_code is None:
|
||||
self.logger.debug('%s game %s is missing StatusId',
|
||||
self.__class__.__name__, ret['id'])
|
||||
status_code = 1
|
||||
ret['status'] = status_map[status_code]
|
||||
|
||||
for ret_key, game_key in (('home', 'HomeTeam'), ('away', 'AwayTeam')):
|
||||
_update('%s_score' % ret_key, '%s:Score' % game_key, default=0)
|
||||
_update('%s_name' % ret_key, '%s:Name' % game_key)
|
||||
_update('%s_name_short' % ret_key, '%s:ShortName' % game_key)
|
||||
_update('%s_abbrev' % ret_key, '%s:Code' % game_key)
|
||||
_update('%s_wins' % ret_key, '%s:Stats:Won' % game_key, default=0)
|
||||
_update('%s_losses' % ret_key, '%s:Stats:Lost' % game_key)
|
||||
_update('%s_draws' % ret_key, '%s:Stats:Drawn' % game_key)
|
||||
_update('%s_points' % ret_key, '%s:Stats:Points' % game_key)
|
||||
|
||||
try:
|
||||
game_time = datetime.strptime(
|
||||
game.get('DateTime', ''),
|
||||
'%Y-%m-%dT%H:%M:%S'
|
||||
)
|
||||
except ValueError as exc:
|
||||
# Log when the date retrieved from the API return doesn't match the
|
||||
# expected format (to help troubleshoot API changes), and set an
|
||||
# actual datetime so format strings work as expected. The times
|
||||
# will all be wrong, but the logging here will help us make the
|
||||
# necessary changes to adapt to any API changes.
|
||||
self.logger.error(
|
||||
'Error encountered determining game time for %s game %s:',
|
||||
self.__class__.__name__,
|
||||
ret['id'],
|
||||
exc_info=True
|
||||
)
|
||||
game_time = datetime.datetime(1970, 1, 1)
|
||||
|
||||
london = pytz.timezone('Europe/London')
|
||||
ret['start_time'] = london.localize(game_time).astimezone()
|
||||
|
||||
self.logger.debug('Returned %s formatter data: %s',
|
||||
self.__class__.__name__, ret)
|
||||
|
||||
return ret
|
321
i3pystatus/scores/mlb.py
Normal file
321
i3pystatus/scores/mlb.py
Normal file
@ -0,0 +1,321 @@
|
||||
from i3pystatus.core.util import internet, require
|
||||
from i3pystatus.scores import ScoresBackend
|
||||
|
||||
import copy
|
||||
import json
|
||||
import pytz
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from urllib.request import urlopen
|
||||
|
||||
LIVE_URL = 'http://mlb.mlb.com/mlb/gameday/index.jsp?gid=%s'
|
||||
SCOREBOARD_URL = 'http://m.mlb.com/scoreboard'
|
||||
API_URL = 'http://gd2.mlb.com/components/game/mlb/year_%04d/month_%02d/day_%02d/miniscoreboard.json'
|
||||
|
||||
|
||||
class MLB(ScoresBackend):
|
||||
'''
|
||||
Backend to retrieve MLB scores. For usage examples, see :py:mod:`here
|
||||
<.scores>`.
|
||||
|
||||
.. rubric:: Available formatters
|
||||
|
||||
* `{home_name}` — Name of home team
|
||||
* `{home_city}` — Name of home team's city
|
||||
* `{home_abbrev}` — 2 or 3-letter abbreviation for home team's city
|
||||
* `{home_score}` — Home team's current score
|
||||
* `{home_wins}` — Home team's number of wins
|
||||
* `{home_losses}` — Home team's number of losses
|
||||
* `{home_favorite}` — Displays the value for the :py:mod:`.scores` module's
|
||||
``favorite`` attribute, if the home team is one of the teams being
|
||||
followed. Otherwise, this formatter will be blank.
|
||||
* `{away_name}` — Name of away team
|
||||
* `{away_city}` — Name of away team's city
|
||||
* `{away_abbrev}` — 2 or 3-letter abbreviation for away team's city
|
||||
* `{away_score}` — Away team's current score
|
||||
* `{away_wins}` — Away team's number of wins
|
||||
* `{away_losses}` — Away team's number of losses
|
||||
* `{away_favorite}` — Displays the value for the :py:mod:`.scores` module's
|
||||
``favorite`` attribute, if the away team is one of the teams being
|
||||
followed. Otherwise, this formatter will be blank.
|
||||
* `{top_bottom}` — Displays the value of either ``inning_top`` or
|
||||
``inning_bottom`` based on whether the game is in the top or bottom of an
|
||||
inning.
|
||||
* `{inning}` — Current inning
|
||||
* `{outs}` — Number of outs in current inning
|
||||
* `{venue}` — Name of ballpark where game is being played
|
||||
* `{start_time}` — Start time of game in system's localtime (supports
|
||||
strftime formatting, e.g. `{start_time:%I:%M %p}`)
|
||||
* `{delay}` — Reason for delay, if game is currently delayed. Otherwise,
|
||||
this formatter will be blank.
|
||||
* `{postponed}` — Reason for postponement, if game has been postponed.
|
||||
Otherwise, this formatter will be blank.
|
||||
* `{extra_innings}` — When a game lasts longer than 9 innings, this
|
||||
formatter will show that number of innings. Otherwise, it will blank.
|
||||
|
||||
.. rubric:: Team abbreviations
|
||||
|
||||
* **ARI** — Arizona Diamondbacks
|
||||
* **ATL** — Atlanta Braves
|
||||
* **BAL** — Baltimore Orioles
|
||||
* **BOS** — Boston Red Sox
|
||||
* **CHC** — Chicago Cubs
|
||||
* **CIN** — Cincinnati Reds
|
||||
* **CLE** — Cleveland Indians
|
||||
* **COL** — Colorado Rockies
|
||||
* **CWS** — Chicago White Sox
|
||||
* **DET** — Detroit Tigers
|
||||
* **HOU** — Houston Astros
|
||||
* **KC** — Kansas City Royals
|
||||
* **LAA** — Los Angeles Angels of Anaheim
|
||||
* **LAD** — Los Angeles Dodgers
|
||||
* **MIA** — Miami Marlins
|
||||
* **MIL** — Milwaukee Brewers
|
||||
* **MIN** — Minnesota Twins
|
||||
* **NYY** — New York Yankees
|
||||
* **NYM** — New York Mets
|
||||
* **OAK** — Oakland Athletics
|
||||
* **PHI** — Philadelphia Phillies
|
||||
* **PIT** — Pittsburgh Pirates
|
||||
* **SD** — San Diego Padres
|
||||
* **SEA** — Seattle Mariners
|
||||
* **SF** — San Francisco Giants
|
||||
* **STL** — St. Louis Cardinals
|
||||
* **TB** — Tampa Bay Rays
|
||||
* **TEX** — Texas Rangers
|
||||
* **TOR** — Toronto Blue Jays
|
||||
* **WSH** — Washington Nationals
|
||||
'''
|
||||
interval = 300
|
||||
|
||||
settings = (
|
||||
('favorite_teams', 'List of abbreviations of favorite teams. Games '
|
||||
'for these teams will appear first in the scroll '
|
||||
'list. A detailed description of how games are '
|
||||
'ordered can be found '
|
||||
':ref:`here <scores-game-order>`.'),
|
||||
('all_games', 'If set to ``True``, all games will be present in '
|
||||
'the scroll list. If set to ``False``, then only '
|
||||
'games from **favorite_teams** will be present in '
|
||||
'the scroll list.'),
|
||||
('display_order', 'When **all_games** is set to ``True``, this '
|
||||
'option will dictate the order in which games from '
|
||||
'teams not in **favorite_teams** are displayed'),
|
||||
('format_no_games', 'Format used when no tracked games are scheduled '
|
||||
'for the current day (does not support formatter '
|
||||
'placeholders)'),
|
||||
('format_pregame', 'Format used when the game has not yet started'),
|
||||
('format_in_progress', 'Format used when the game is in progress'),
|
||||
('format_final', 'Format used when the game is complete'),
|
||||
('format_postponed', 'Format used when the game has been postponed'),
|
||||
('inning_top', 'Value for the ``{top_bottom}`` formatter when game '
|
||||
'is in the top half of an inning'),
|
||||
('inning_bottom', 'Value for the ``{top_bottom}`` formatter when game '
|
||||
'is in the bottom half of an inning'),
|
||||
('team_colors', 'Dictionary mapping team abbreviations to hex color '
|
||||
'codes. If overridden, the passed values will be '
|
||||
'merged with the defaults, so it is not necessary to '
|
||||
'define all teams if specifying this value.'),
|
||||
('date', 'Date for which to display game scores, in **YYYY-MM-DD** '
|
||||
'format. If unspecified, the current day\'s games will be '
|
||||
'displayed starting at 10am Eastern time, with last '
|
||||
'evening\'s scores being shown before then. This option '
|
||||
'exists primarily for troubleshooting purposes.'),
|
||||
('live_url', 'Alternate URL string to launch MLB Gameday. This value '
|
||||
'should not need to be changed'),
|
||||
('scoreboard_url', 'Link to the MLB.com scoreboard page. Like '
|
||||
'**live_url**, this value should not need to be '
|
||||
'changed.'),
|
||||
('api_url', 'Alternate URL string from which to retrieve score data. '
|
||||
'Like **live_url*** this value should not need to be '
|
||||
'changed.'),
|
||||
)
|
||||
|
||||
required = ()
|
||||
|
||||
_default_colors = {
|
||||
'ARI': '#A71930',
|
||||
'ATL': '#CE1141',
|
||||
'BAL': '#DF4601',
|
||||
'BOS': '#BD3039',
|
||||
'CHC': '#004EC1',
|
||||
'CIN': '#C6011F',
|
||||
'CLE': '#E31937',
|
||||
'COL': '#5E5EB6',
|
||||
'CWS': '#DADADA',
|
||||
'DET': '#FF6600',
|
||||
'HOU': '#EB6E1F',
|
||||
'KC': '#0046DD',
|
||||
'LAA': '#BA0021',
|
||||
'LAD': '#005A9C',
|
||||
'MIA': '#F14634',
|
||||
'MIL': '#0747CC',
|
||||
'MIN': '#D31145',
|
||||
'NYY': '#0747CC',
|
||||
'NYM': '#FF5910',
|
||||
'OAK': '#006659',
|
||||
'PHI': '#E81828',
|
||||
'PIT': '#FFCC01',
|
||||
'SD': '#285F9A',
|
||||
'SEA': '#2E8B90',
|
||||
'SF': '#FD5A1E',
|
||||
'STL': '#B53B30',
|
||||
'TB': '#8FBCE6',
|
||||
'TEX': '#C0111F',
|
||||
'TOR': '#0046DD',
|
||||
'WSH': '#C70003',
|
||||
}
|
||||
|
||||
_valid_teams = [x for x in _default_colors]
|
||||
_valid_display_order = ['in_progress', 'final', 'postponed', 'pregame']
|
||||
|
||||
display_order = _valid_display_order
|
||||
format_no_games = 'MLB: No games'
|
||||
format_pregame = '[{scroll} ]MLB: [{away_favorite} ]{away_abbrev} ({away_wins}-{away_losses}) at [{home_favorite} ]{home_abbrev} ({home_wins}-{home_losses}) {start_time:%H:%M %Z}[ ({delay} Delay)]'
|
||||
format_in_progress = '[{scroll} ]MLB: [{away_favorite} ]{away_abbrev} {away_score}, [{home_favorite} ]{home_abbrev} {home_score} ({top_bottom} {inning}, {outs} Out)[ ({delay} Delay)]'
|
||||
format_final = '[{scroll} ]MLB: [{away_favorite} ]{away_abbrev} {away_score} ({away_wins}-{away_losses}) at [{home_favorite} ]{home_abbrev} {home_score} ({home_wins}-{home_losses}) (Final[/{extra_innings}])'
|
||||
format_postponed = '[{scroll} ]MLB: [{away_favorite} ]{away_abbrev} ({away_wins}-{away_losses}) at [{home_favorite} ]{home_abbrev} ({home_wins}-{home_losses}) (PPD: {postponed})'
|
||||
inning_top = 'Top'
|
||||
inning_bottom = 'Bot'
|
||||
team_colors = _default_colors
|
||||
live_url = LIVE_URL
|
||||
scoreboard_url = SCOREBOARD_URL
|
||||
api_url = API_URL
|
||||
|
||||
@require(internet)
|
||||
def check_scores(self):
|
||||
self.get_api_date()
|
||||
url = self.api_url % (self.date.year, self.date.month, self.date.day)
|
||||
|
||||
game_list = self.get_nested(self.api_request(url),
|
||||
'data:games:game',
|
||||
default=[])
|
||||
|
||||
# Convert list of games to dictionary for easy reference later on
|
||||
data = {}
|
||||
team_game_map = {}
|
||||
for game in game_list:
|
||||
try:
|
||||
id_ = game['id']
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
try:
|
||||
for team in (game['home_name_abbrev'], game['away_name_abbrev']):
|
||||
team = team.upper()
|
||||
if team in self.favorite_teams:
|
||||
team_game_map.setdefault(team, []).append(id_)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
data[id_] = game
|
||||
|
||||
self.interpret_api_return(data, team_game_map)
|
||||
|
||||
def process_game(self, game):
|
||||
ret = {}
|
||||
|
||||
def _update(ret_key, game_key=None, callback=None, default='?'):
|
||||
ret[ret_key] = self.get_nested(game,
|
||||
game_key or ret_key,
|
||||
callback=callback,
|
||||
default=default)
|
||||
|
||||
self.logger.debug('Processing %s game data: %s',
|
||||
self.__class__.__name__, game)
|
||||
|
||||
for key in ('id', 'venue'):
|
||||
_update(key)
|
||||
|
||||
for key in ('inning', 'outs'):
|
||||
_update(key, callback=self.force_int, default=0)
|
||||
|
||||
ret['live_url'] = self.live_url % game['gameday_link']
|
||||
|
||||
for team in ('home', 'away'):
|
||||
_update('%s_wins' % team, '%s_win' % team,
|
||||
callback=self.force_int)
|
||||
_update('%s_losses' % team, '%s_loss' % team,
|
||||
callback=self.force_int)
|
||||
_update('%s_score' % team, '%s_team_runs' % team,
|
||||
callback=self.force_int, default=0)
|
||||
|
||||
_update('%s_abbrev' % team, '%s_name_abbrev' % team)
|
||||
for item in ('city', 'name'):
|
||||
_update('%s_%s' % (team, item), '%s_team_%s' % (team, item))
|
||||
|
||||
try:
|
||||
ret['status'] = game.get('status').lower().replace(' ', '_')
|
||||
except AttributeError:
|
||||
# During warmup ret['status'] may be a dictionary. Treat these as
|
||||
# pregame
|
||||
ret['status'] = 'pregame'
|
||||
|
||||
for key in ('delay', 'postponed'):
|
||||
ret[key] = ''
|
||||
|
||||
if ret['status'] == 'delayed_start':
|
||||
ret['status'] = 'pregame'
|
||||
ret['delay'] = game.get('reason', 'Unknown')
|
||||
elif ret['status'] == 'delayed':
|
||||
ret['status'] = 'in_progress'
|
||||
ret['delay'] = game.get('reason', 'Unknown')
|
||||
elif ret['status'] == 'postponed':
|
||||
ret['postponed'] = game.get('reason', 'Unknown Reason')
|
||||
elif ret['status'] == 'game_over':
|
||||
ret['status'] = 'final'
|
||||
elif ret['status'] not in ('in_progress', 'final'):
|
||||
ret['status'] = 'pregame'
|
||||
|
||||
try:
|
||||
inning = game.get('inning', '0')
|
||||
ret['extra_innings'] = inning \
|
||||
if ret['status'] == 'final' and int(inning) > 9 \
|
||||
else ''
|
||||
except ValueError:
|
||||
ret['extra_innings'] = ''
|
||||
|
||||
top_bottom = game.get('top_inning')
|
||||
ret['top_bottom'] = self.inning_top if top_bottom == 'Y' \
|
||||
else self.inning_bottom if top_bottom == 'N' \
|
||||
else ''
|
||||
|
||||
time_zones = {
|
||||
'PT': 'US/Pacific',
|
||||
'MT': 'US/Mountain',
|
||||
'CT': 'US/Central',
|
||||
'ET': 'US/Eastern',
|
||||
}
|
||||
game_tz = pytz.timezone(
|
||||
time_zones.get(
|
||||
game.get('time_zone', 'ET'),
|
||||
'US/Eastern'
|
||||
)
|
||||
)
|
||||
game_time_str = ' '.join((
|
||||
game.get('time_date', ''),
|
||||
game.get('ampm', '')
|
||||
))
|
||||
try:
|
||||
game_time = datetime.strptime(game_time_str, '%Y/%m/%d %I:%M %p')
|
||||
except ValueError as exc:
|
||||
# Log when the date retrieved from the API return doesn't match the
|
||||
# expected format (to help troubleshoot API changes), and set an
|
||||
# actual datetime so format strings work as expected. The times
|
||||
# will all be wrong, but the logging here will help us make the
|
||||
# necessary changes to adapt to any API changes.
|
||||
self.logger.error(
|
||||
'Error encountered determining %s game time for game %s:',
|
||||
self.__class__.__name__,
|
||||
game['id'],
|
||||
exc_info=True
|
||||
)
|
||||
game_time = datetime.datetime(1970, 1, 1)
|
||||
|
||||
ret['start_time'] = game_tz.localize(game_time).astimezone()
|
||||
|
||||
self.logger.debug('Returned %s formatter data: %s',
|
||||
self.__class__.__name__, ret)
|
||||
|
||||
return ret
|
332
i3pystatus/scores/nba.py
Normal file
332
i3pystatus/scores/nba.py
Normal file
@ -0,0 +1,332 @@
|
||||
from i3pystatus.core.util import internet, require
|
||||
from i3pystatus.scores import ScoresBackend
|
||||
|
||||
import copy
|
||||
import pytz
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
LIVE_URL = 'http://www.nba.com/gametracker/#/%s/lp'
|
||||
SCOREBOARD_URL = 'http://www.nba.com/scores'
|
||||
API_URL = 'http://data.nba.com/data/10s/json/cms/noseason/scoreboard/%04d%02d%02d/games.json'
|
||||
STANDINGS_URL = 'http://data.nba.com/data/json/cms/%s/league/standings.json'
|
||||
|
||||
|
||||
class NBA(ScoresBackend):
|
||||
'''
|
||||
Backend to retrieve NBA scores. For usage examples, see :py:mod:`here
|
||||
<.scores>`.
|
||||
|
||||
.. rubric:: Available formatters
|
||||
|
||||
* `{home_name}` — Name of home team
|
||||
* `{home_city}` — Name of home team's city
|
||||
* `{home_abbrev}` — 3-letter abbreviation for home team's city
|
||||
* `{home_score}` — Home team's current score
|
||||
* `{home_wins}` — Home team's number of wins
|
||||
* `{home_losses}` — Home team's number of losses
|
||||
* `{home_seed}` — During the playoffs, shows the home team's playoff seed.
|
||||
When not in the playoffs, this formatter will be blank.
|
||||
* `{home_favorite}` — Displays the value for the :py:mod:`.scores` module's
|
||||
``favorite`` attribute, if the home team is one of the teams being
|
||||
followed. Otherwise, this formatter will be blank.
|
||||
* `{away_name}` — Name of away team
|
||||
* `{away_city}` — Name of away team's city
|
||||
* `{away_abbrev}` — 2 or 3-letter abbreviation for away team's city
|
||||
* `{away_score}` — Away team's current score
|
||||
* `{away_wins}` — Away team's number of wins
|
||||
* `{away_losses}` — Away team's number of losses
|
||||
* `{away_seed}` — During the playoffs, shows the away team's playoff seed.
|
||||
When not in the playoffs, this formatter will be blank.
|
||||
* `{away_favorite}` — Displays the value for the :py:mod:`.scores` module's
|
||||
``favorite`` attribute, if the away team is one of the teams being
|
||||
followed. Otherwise, this formatter will be blank.
|
||||
* `{time_remaining}` — Time remaining in the current quarter/OT period
|
||||
* `{quarter}` — Number of the current quarter
|
||||
* `{venue}` — Name of arena where game is being played
|
||||
* `{start_time}` — Start time of game in system's localtime (supports
|
||||
strftime formatting, e.g. `{start_time:%I:%M %p}`)
|
||||
* `{overtime}` — If the game ended in overtime, this formatter will show
|
||||
``OT``. If the game ended in regulation, or has not yet completed, this
|
||||
formatter will be blank.
|
||||
|
||||
.. rubric:: Team abbreviations
|
||||
|
||||
* **ATL** — Atlanta Hawks
|
||||
* **BKN** — Brooklyn Nets
|
||||
* **BOS** — Boston Celtics
|
||||
* **CHA** — Charlotte Hornets
|
||||
* **CHI** — Chicago Bulls
|
||||
* **CLE** — Cleveland Cavaliers
|
||||
* **DAL** — Dallas Mavericks
|
||||
* **DEN** — Denver Nuggets
|
||||
* **DET** — Detroit Pistons
|
||||
* **GSW** — Golden State Warriors
|
||||
* **HOU** — Houston Rockets
|
||||
* **IND** — Indiana Pacers
|
||||
* **MIA** — Miami Heat
|
||||
* **MEM** — Memphis Grizzlies
|
||||
* **MIL** — Milwaukee Bucks
|
||||
* **LAC** — Los Angeles Clippers
|
||||
* **LAL** — Los Angeles Lakers
|
||||
* **MIN** — Minnesota Timberwolves
|
||||
* **NOP** — New Orleans Pelicans
|
||||
* **NYK** — New York Knicks
|
||||
* **OKC** — Oklahoma City Thunder
|
||||
* **ORL** — Orlando Magic
|
||||
* **PHI** — Philadelphia 76ers
|
||||
* **PHX** — Phoenix Suns
|
||||
* **POR** — Portland Trailblazers
|
||||
* **SAC** — Sacramento Kings
|
||||
* **SAS** — San Antonio Spurs
|
||||
* **TOR** — Toronto Raptors
|
||||
* **UTA** — Utah Jazz
|
||||
* **WAS** — Washington Wizards
|
||||
'''
|
||||
interval = 300
|
||||
|
||||
settings = (
|
||||
('favorite_teams', 'List of abbreviations of favorite teams. Games '
|
||||
'for these teams will appear first in the scroll '
|
||||
'list. A detailed description of how games are '
|
||||
'ordered can be found '
|
||||
':ref:`here <scores-game-order>`.'),
|
||||
('all_games', 'If set to ``True``, all games will be present in '
|
||||
'the scroll list. If set to ``False``, then only '
|
||||
'games from **favorite_teams** will be present in '
|
||||
'the scroll list.'),
|
||||
('display_order', 'When **all_games** is set to ``True``, this '
|
||||
'option will dictate the order in which games from '
|
||||
'teams not in **favorite_teams** are displayed'),
|
||||
('format_no_games', 'Format used when no tracked games are scheduled '
|
||||
'for the current day (does not support formatter '
|
||||
'placeholders)'),
|
||||
('format_pregame', 'Format used when the game has not yet started'),
|
||||
('format_in_progress', 'Format used when the game is in progress'),
|
||||
('format_final', 'Format used when the game is complete'),
|
||||
('team_colors', 'Dictionary mapping team abbreviations to hex color '
|
||||
'codes. If overridden, the passed values will be '
|
||||
'merged with the defaults, so it is not necessary to '
|
||||
'define all teams if specifying this value.'),
|
||||
('date', 'Date for which to display game scores, in **YYYY-MM-DD** '
|
||||
'format. If unspecified, the current day\'s games will be '
|
||||
'displayed starting at 10am Eastern time, with last '
|
||||
'evening\'s scores being shown before then. This option '
|
||||
'exists primarily for troubleshooting purposes.'),
|
||||
('live_url', 'URL string to launch NBA Game Tracker. This value '
|
||||
'should not need to be changed.'),
|
||||
('scoreboard_url', 'Link to the NBA.com scoreboard page. Like '
|
||||
'**live_url**, this value should not need to be '
|
||||
'changed.'),
|
||||
('api_url', 'Alternate URL string from which to retrieve score data. '
|
||||
'Like, **live_url**, this value should not need to be '
|
||||
'changed.'),
|
||||
('standings_url', 'Alternate URL string from which to retrieve team '
|
||||
'standings. Like **live_url**, this value should '
|
||||
'not need to be changed.'),
|
||||
)
|
||||
|
||||
required = ()
|
||||
|
||||
_default_colors = {
|
||||
'ATL': '#E2383F',
|
||||
'BKN': '#DADADA',
|
||||
'BOS': '#178D58',
|
||||
'CHA': '#00798D',
|
||||
'CHI': '#CD1041',
|
||||
'CLE': '#FDBA31',
|
||||
'DAL': '#006BB7',
|
||||
'DEN': '#5593C3',
|
||||
'DET': '#207EC0',
|
||||
'GSW': '#DEB934',
|
||||
'HOU': '#CD1042',
|
||||
'IND': '#FFBB33',
|
||||
'MIA': '#A72249',
|
||||
'MEM': '#628BBC',
|
||||
'MIL': '#4C7B4B',
|
||||
'LAC': '#ED174C',
|
||||
'LAL': '#FDB827',
|
||||
'MIN': '#35749F',
|
||||
'NOP': '#A78F59',
|
||||
'NYK': '#F68428',
|
||||
'OKC': '#F05033',
|
||||
'ORL': '#1980CB',
|
||||
'PHI': '#006BB7',
|
||||
'PHX': '#E76120',
|
||||
'POR': '#B03037',
|
||||
'SAC': '#7A58A1',
|
||||
'SAS': '#DADADA',
|
||||
'TOR': '#CD112C',
|
||||
'UTA': '#4B7059',
|
||||
'WAS': '#E51735',
|
||||
}
|
||||
|
||||
_valid_teams = [x for x in _default_colors]
|
||||
_valid_display_order = ['in_progress', 'final', 'pregame']
|
||||
|
||||
display_order = _valid_display_order
|
||||
format_no_games = 'NBA: No games'
|
||||
format_pregame = '[{scroll} ]NBA: [{away_favorite} ][{away_seed} ]{away_abbrev} ({away_wins}-{away_losses}) at [{home_favorite} ][{home_seed} ]{home_abbrev} ({home_wins}-{home_losses}) {start_time:%H:%M %Z}'
|
||||
format_in_progress = '[{scroll} ]NBA: [{away_favorite} ]{away_abbrev} {away_score}[ ({away_power_play})], [{home_favorite} ]{home_abbrev} {home_score}[ ({home_power_play})] ({time_remaining} {quarter})'
|
||||
format_final = '[{scroll} ]NBA: [{away_favorite} ]{away_abbrev} {away_score} ({away_wins}-{away_losses}) at [{home_favorite} ]{home_abbrev} {home_score} ({home_wins}-{home_losses}) (Final[/{overtime}])'
|
||||
team_colors = _default_colors
|
||||
live_url = LIVE_URL
|
||||
scoreboard_url = SCOREBOARD_URL
|
||||
api_url = API_URL
|
||||
standings_url = STANDINGS_URL
|
||||
|
||||
def check_scores(self):
|
||||
self.get_api_date()
|
||||
url = self.api_url % (self.date.year, self.date.month, self.date.day)
|
||||
|
||||
response = self.api_request(url)
|
||||
game_list = self.get_nested(response,
|
||||
'sports_content:games:game',
|
||||
default=[])
|
||||
|
||||
standings_year = self.get_nested(
|
||||
response,
|
||||
'sports_content:sports_meta:season_meta:standings_season_year',
|
||||
default=self.date.year,
|
||||
)
|
||||
|
||||
stats_list = self.get_nested(
|
||||
self.api_request(self.standings_url % standings_year),
|
||||
'sports_content:standings:team',
|
||||
default=[],
|
||||
)
|
||||
team_stats = {}
|
||||
for item in stats_list:
|
||||
try:
|
||||
key = item.pop('abbreviation')
|
||||
except KeyError:
|
||||
self.logger.debug('Error occurred obtaining team stats',
|
||||
exc_info=True)
|
||||
continue
|
||||
team_stats[key] = item.get('team_stats', {})
|
||||
|
||||
self.logger.debug('%s team stats: %s',
|
||||
self.__class__.__name__, team_stats)
|
||||
|
||||
# Convert list of games to dictionary for easy reference later on
|
||||
data = {}
|
||||
team_game_map = {}
|
||||
for game in game_list:
|
||||
try:
|
||||
id_ = game['game_url']
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
try:
|
||||
for key in ('home', 'visitor'):
|
||||
team = game[key]['abbreviation'].upper()
|
||||
if team in self.favorite_teams:
|
||||
team_game_map.setdefault(team, []).append(id_)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
data[id_] = game
|
||||
# Merge in the team stats, because they are not returned in the
|
||||
# initial API request.
|
||||
for key in ('home', 'visitor'):
|
||||
team = data[id_][key]['abbreviation'].upper()
|
||||
data[id_][key].update(team_stats.get(team, {}))
|
||||
|
||||
self.interpret_api_return(data, team_game_map)
|
||||
|
||||
def process_game(self, game):
|
||||
ret = {}
|
||||
|
||||
def _update(ret_key, game_key=None, callback=None, default='?'):
|
||||
ret[ret_key] = self.get_nested(game,
|
||||
game_key or ret_key,
|
||||
callback=callback,
|
||||
default=default)
|
||||
|
||||
self.logger.debug('Processing %s game data: %s',
|
||||
self.__class__.__name__, game)
|
||||
|
||||
_update('id', 'game_url')
|
||||
ret['live_url'] = self.live_url % ret['id']
|
||||
|
||||
status_map = {
|
||||
'1': 'pregame',
|
||||
'2': 'in_progress',
|
||||
'3': 'final',
|
||||
}
|
||||
period_data = game.get('period_time', {})
|
||||
status_code = period_data.get('game_status', '1')
|
||||
status = status_map.get(status_code)
|
||||
if status is None:
|
||||
self.logger.debug('Unknown %s game status code \'%s\'',
|
||||
self.__class__.__name__, status_code)
|
||||
status_code = '1'
|
||||
ret['status'] = status_map[status_code]
|
||||
|
||||
if ret['status'] in ('in_progress', 'final'):
|
||||
period_number = int(period_data.get('period_value', 1))
|
||||
total_periods = int(period_data.get('total_periods', 0))
|
||||
period_diff = period_number - total_periods
|
||||
ret['quarter'] = 'OT' \
|
||||
if period_diff == 1 \
|
||||
else '%dOT' if period_diff > 1 \
|
||||
else self.add_ordinal(period_number)
|
||||
else:
|
||||
ret['quarter'] = ''
|
||||
|
||||
ret['time_remaining'] = period_data.get('game_clock')
|
||||
if ret['time_remaining'] == '':
|
||||
ret['time_remaining'] = 'End'
|
||||
elif ret['time_remaining'] is None:
|
||||
ret['time_remaining'] = ''
|
||||
ret['overtime'] = ret['quarter'] if 'OT' in ret['quarter'] else ''
|
||||
|
||||
_update('venue', 'arena')
|
||||
|
||||
for ret_key, game_key in (('home', 'home'), ('away', 'visitor')):
|
||||
_update('%s_score' % ret_key, '%s:score' % game_key,
|
||||
callback=self.force_int, default=0)
|
||||
_update('%s_city' % ret_key, '%s:city' % game_key)
|
||||
_update('%s_name' % ret_key, '%s:nickname' % game_key)
|
||||
_update('%s_abbrev' % ret_key, '%s:abbreviation' % game_key)
|
||||
if 'playoffs' in game:
|
||||
_update('%s_wins' % ret_key, 'playoffs:%s_wins' % game_key,
|
||||
callback=self.force_int, default=0)
|
||||
_update('%s_seed' % ret_key, 'playoffs:%s_seed' % game_key,
|
||||
callback=self.force_int, default=0)
|
||||
else:
|
||||
_update('%s_wins' % ret_key, '%s:wins' % game_key,
|
||||
callback=self.force_int, default=0)
|
||||
_update('%s_losses' % ret_key, '%s:losses' % game_key,
|
||||
callback=self.force_int, default=0)
|
||||
ret['%s_seed' % ret_key] = ''
|
||||
|
||||
if 'playoffs' in game:
|
||||
ret['home_losses'] = ret['away_wins']
|
||||
ret['away_losses'] = ret['home_wins']
|
||||
|
||||
# From API data, date is YYYYMMDD, time is HHMM
|
||||
game_time_str = '%s%s' % (game.get('date', ''), game.get('time', ''))
|
||||
try:
|
||||
game_time = datetime.strptime(game_time_str, '%Y%m%d%H%M')
|
||||
except ValueError as exc:
|
||||
# Log when the date retrieved from the API return doesn't match the
|
||||
# expected format (to help troubleshoot API changes), and set an
|
||||
# actual datetime so format strings work as expected. The times
|
||||
# will all be wrong, but the logging here will help us make the
|
||||
# necessary changes to adapt to any API changes.
|
||||
self.logger.error(
|
||||
'Error encountered determining game time for %s game %s:',
|
||||
self.__class__.__name__,
|
||||
game['id'],
|
||||
exc_info=True
|
||||
)
|
||||
game_time = datetime.datetime(1970, 1, 1)
|
||||
|
||||
eastern = pytz.timezone('US/Eastern')
|
||||
ret['start_time'] = eastern.localize(game_time).astimezone()
|
||||
|
||||
self.logger.debug('Returned %s formatter data: %s',
|
||||
self.__class__.__name__, ret)
|
||||
|
||||
return ret
|
302
i3pystatus/scores/nhl.py
Normal file
302
i3pystatus/scores/nhl.py
Normal file
@ -0,0 +1,302 @@
|
||||
from i3pystatus.core.util import internet, require
|
||||
from i3pystatus.scores import ScoresBackend
|
||||
|
||||
import copy
|
||||
import json
|
||||
import pytz
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from urllib.request import urlopen
|
||||
|
||||
LIVE_URL = 'https://www.nhl.com/gamecenter/%s'
|
||||
SCOREBOARD_URL = 'https://www.nhl.com/scores'
|
||||
API_URL = 'https://statsapi.web.nhl.com/api/v1/schedule?startDate=%04d-%02d-%02d&endDate=%04d-%02d-%02d&expand=schedule.teams,schedule.linescore,schedule.broadcasts.all&site=en_nhl&teamId='
|
||||
|
||||
|
||||
class NHL(ScoresBackend):
|
||||
'''
|
||||
Backend to retrieve NHL scores. For usage examples, see :py:mod:`here
|
||||
<.scores>`.
|
||||
|
||||
.. rubric:: Available formatters
|
||||
|
||||
* `{home_name}` — Name of home team
|
||||
* `{home_city}` — Name of home team's city
|
||||
* `{home_abbrev}` — 3-letter abbreviation for home team's city
|
||||
* `{home_score}` — Home team's current score
|
||||
* `{home_wins}` — Home team's number of wins
|
||||
* `{home_losses}` — Home team's number of losses
|
||||
* `{home_otl}` — Home team's number of overtime losses
|
||||
* `{home_favorite}` — Displays the value for the :py:mod:`.scores` module's
|
||||
``favorite`` attribute, if the home team is one of the teams being
|
||||
followed. Otherwise, this formatter will be blank.
|
||||
* `{home_empty_net}` — Shows the value from the ``empty_net`` parameter
|
||||
when the home team's net is empty.
|
||||
* `{away_name}` — Name of away team
|
||||
* `{away_city}` — Name of away team's city
|
||||
* `{away_abbrev}` — 2 or 3-letter abbreviation for away team's city
|
||||
* `{away_score}` — Away team's current score
|
||||
* `{away_wins}` — Away team's number of wins
|
||||
* `{away_losses}` — Away team's number of losses
|
||||
* `{away_otl}` — Away team's number of overtime losses
|
||||
* `{away_favorite}` — Displays the value for the :py:mod:`.scores` module's
|
||||
``favorite`` attribute, if the away team is one of the teams being
|
||||
followed. Otherwise, this formatter will be blank.
|
||||
* `{away_empty_net}` — Shows the value from the ``empty_net`` parameter
|
||||
when the away team's net is empty.
|
||||
* `{period}` — Current period
|
||||
* `{venue}` — Name of arena where game is being played
|
||||
* `{start_time}` — Start time of game in system's localtime (supports
|
||||
strftime formatting, e.g. `{start_time:%I:%M %p}`)
|
||||
* `{overtime}` — If the game ended in overtime or a shootout, this
|
||||
formatter will show ``OT`` kor ``SO``. If the game ended in regulation,
|
||||
or has not yet completed, this formatter will be blank.
|
||||
|
||||
.. rubric:: Team abbreviations
|
||||
|
||||
* **ANA** — Anaheim Ducks
|
||||
* **ARI** — Arizona Coyotes
|
||||
* **BOS** — Boston Bruins
|
||||
* **BUF** — Buffalo Sabres
|
||||
* **CAR** — Carolina Hurricanes
|
||||
* **CBJ** — Columbus Blue Jackets
|
||||
* **CGY** — Calgary Flames
|
||||
* **CHI** — Chicago Blackhawks
|
||||
* **COL** — Colorado Avalanche
|
||||
* **DAL** — Dallas Stars
|
||||
* **DET** — Detroit Red Wings
|
||||
* **EDM** — Edmonton Oilers
|
||||
* **FLA** — Florida Panthers
|
||||
* **LAK** — Los Angeles Kings
|
||||
* **MIN** — Minnesota Wild
|
||||
* **MTL** — Montreal Canadiens
|
||||
* **NJD** — New Jersey Devils
|
||||
* **NSH** — Nashville Predators
|
||||
* **NYI** — New York Islanders
|
||||
* **NYR** — New York Rangers
|
||||
* **OTT** — Ottawa Senators
|
||||
* **PHI** — Philadelphia Flyers
|
||||
* **PIT** — Pittsburgh Penguins
|
||||
* **SJS** — San Jose Sharks
|
||||
* **STL** — St. Louis Blues
|
||||
* **TBL** — Tampa Bay Lightning
|
||||
* **TOR** — Toronto Maple Leafs
|
||||
* **VAN** — Vancouver Canucks
|
||||
* **WPG** — Winnipeg Jets
|
||||
* **WSH** — Washington Capitals
|
||||
'''
|
||||
interval = 300
|
||||
|
||||
settings = (
|
||||
('favorite_teams', 'List of abbreviations of favorite teams. Games '
|
||||
'for these teams will appear first in the scroll '
|
||||
'list. A detailed description of how games are '
|
||||
'ordered can be found '
|
||||
':ref:`here <scores-game-order>`.'),
|
||||
('all_games', 'If set to ``True``, all games will be present in '
|
||||
'the scroll list. If set to ``False``, then only '
|
||||
'games from **favorite_teams** will be present in '
|
||||
'the scroll list.'),
|
||||
('display_order', 'When **all_games** is set to ``True``, this '
|
||||
'option will dictate the order in which games from '
|
||||
'teams not in **favorite_teams** are displayed'),
|
||||
('format_no_games', 'Format used when no tracked games are scheduled '
|
||||
'for the current day (does not support formatter '
|
||||
'placeholders)'),
|
||||
('format_pregame', 'Format used when the game has not yet started'),
|
||||
('format_in_progress', 'Format used when the game is in progress'),
|
||||
('format_final', 'Format used when the game is complete'),
|
||||
('empty_net', 'Value for the ``{away_empty_net}`` or '
|
||||
'``{home_empty_net}`` formatter when the net is empty. '
|
||||
'When the net is not empty, these formatters will be '
|
||||
'empty strings.'),
|
||||
('team_colors', 'Dictionary mapping team abbreviations to hex color '
|
||||
'codes. If overridden, the passed values will be '
|
||||
'merged with the defaults, so it is not necessary to '
|
||||
'define all teams if specifying this value.'),
|
||||
('date', 'Date for which to display game scores, in **YYYY-MM-DD** '
|
||||
'format. If unspecified, the current day\'s games will be '
|
||||
'displayed starting at 10am Eastern time, with last '
|
||||
'evening\'s scores being shown before then. This option '
|
||||
'exists primarily for troubleshooting purposes.'),
|
||||
('live_url', 'URL string to launch NHL GameCenter. This value should '
|
||||
'not need to be changed.'),
|
||||
('scoreboard_url', 'Link to the NHL.com scoreboard page. Like '
|
||||
'**live_url**, this value should not need to be '
|
||||
'changed.'),
|
||||
('api_url', 'Alternate URL string from which to retrieve score data. '
|
||||
'Like **live_url**, this value should not need to be '
|
||||
'changed.'),
|
||||
)
|
||||
|
||||
required = ()
|
||||
|
||||
_default_colors = {
|
||||
'ANA': '#B4A277',
|
||||
'ARI': '#AC313A',
|
||||
'BOS': '#F6BD27',
|
||||
'BUF': '#1568C5',
|
||||
'CAR': '#FA272E',
|
||||
'CBJ': '#1568C5',
|
||||
'CGY': '#D23429',
|
||||
'CHI': '#CD0E24',
|
||||
'COL': '#9F415B',
|
||||
'DAL': '#058158',
|
||||
'DET': '#E51937',
|
||||
'EDM': '#2F6093',
|
||||
'FLA': '#E51837',
|
||||
'LAK': '#DADADA',
|
||||
'MIN': '#176B49',
|
||||
'MTL': '#C8011D',
|
||||
'NJD': '#CC0000',
|
||||
'NSH': '#FDB71A',
|
||||
'NYI': '#F8630D',
|
||||
'NYR': '#1576CA',
|
||||
'OTT': '#C50B2F',
|
||||
'PHI': '#FF690B',
|
||||
'PIT': '#D9CBAE',
|
||||
'SJS': '#007888',
|
||||
'STL': '#1764AD',
|
||||
'TBL': '#296AD5',
|
||||
'TOR': '#296AD5',
|
||||
'VAN': '#0454FA',
|
||||
'WPG': '#1568C5',
|
||||
'WSH': '#E51937',
|
||||
}
|
||||
|
||||
_valid_teams = [x for x in _default_colors]
|
||||
_valid_display_order = ['in_progress', 'final', 'pregame']
|
||||
|
||||
display_order = _valid_display_order
|
||||
format_no_games = 'NHL: No games'
|
||||
format_pregame = '[{scroll} ]NHL: [{away_favorite} ]{away_abbrev} ({away_wins}-{away_losses}-{away_otl}) at [{home_favorite} ]{home_abbrev} ({home_wins}-{home_losses}-{home_otl}) {start_time:%H:%M %Z}'
|
||||
format_in_progress = '[{scroll} ]NHL: [{away_favorite} ]{away_abbrev} {away_score}[ ({away_power_play})][ ({away_empty_net})], [{home_favorite} ]{home_abbrev} {home_score}[ ({home_power_play})][ ({home_empty_net})] ({time_remaining} {period})'
|
||||
format_final = '[{scroll} ]NHL: [{away_favorite} ]{away_abbrev} {away_score} ({away_wins}-{away_losses}-{away_otl}) at [{home_favorite} ]{home_abbrev} {home_score} ({home_wins}-{home_losses}-{home_otl}) (Final[/{overtime}])'
|
||||
empty_net = 'EN'
|
||||
team_colors = _default_colors
|
||||
live_url = LIVE_URL
|
||||
scoreboard_url = SCOREBOARD_URL
|
||||
api_url = API_URL
|
||||
|
||||
@require(internet)
|
||||
def check_scores(self):
|
||||
self.get_api_date()
|
||||
url = self.api_url % (self.date.year, self.date.month, self.date.day,
|
||||
self.date.year, self.date.month, self.date.day)
|
||||
|
||||
game_list = self.get_nested(self.api_request(url),
|
||||
'dates:0:games',
|
||||
default=[])
|
||||
|
||||
# Convert list of games to dictionary for easy reference later on
|
||||
data = {}
|
||||
team_game_map = {}
|
||||
for game in game_list:
|
||||
try:
|
||||
id_ = game['gamePk']
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
try:
|
||||
for key in ('home', 'away'):
|
||||
team = game['teams'][key]['team']['abbreviation'].upper()
|
||||
if team in self.favorite_teams:
|
||||
team_game_map.setdefault(team, []).append(id_)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
data[id_] = game
|
||||
|
||||
self.interpret_api_return(data, team_game_map)
|
||||
|
||||
def process_game(self, game):
|
||||
ret = {}
|
||||
|
||||
def _update(ret_key, game_key=None, callback=None, default='?'):
|
||||
ret[ret_key] = self.get_nested(game,
|
||||
game_key or ret_key,
|
||||
callback=callback,
|
||||
default=default)
|
||||
|
||||
self.logger.debug('Processing %s game data: %s',
|
||||
self.__class__.__name__, game)
|
||||
|
||||
_update('id', 'gamePk')
|
||||
ret['live_url'] = self.live_url % ret['id']
|
||||
_update('period', 'linescore:currentPeriodOrdinal', default='')
|
||||
_update('time_remaining',
|
||||
'linescore:currentPeriodTimeRemaining',
|
||||
lambda x: x.capitalize(),
|
||||
default='')
|
||||
_update('venue', 'venue:name')
|
||||
|
||||
pp_strength = self.get_nested(game,
|
||||
'linescore:powerPlayStrength',
|
||||
default='')
|
||||
for team in ('home', 'away'):
|
||||
_update('%s_score' % team,
|
||||
'teams:%s:score' % team,
|
||||
callback=self.force_int,
|
||||
default=0)
|
||||
_update('%s_wins' % team,
|
||||
'teams:%s:leagueRecord:wins' % team,
|
||||
callback=self.force_int,
|
||||
default=0)
|
||||
_update('%s_losses' % team,
|
||||
'teams:%s:leagueRecord:losses' % team,
|
||||
callback=self.force_int,
|
||||
default=0)
|
||||
_update('%s_otl' % team,
|
||||
'teams:%s:leagueRecord:ot' % team,
|
||||
callback=self.force_int,
|
||||
default=0)
|
||||
|
||||
_update('%s_city' % team, 'teams:%s:team:shortName' % team)
|
||||
_update('%s_name' % team, 'teams:%s:team:teamName' % team)
|
||||
_update('%s_abbrev' % team, 'teams:%s:team:abbreviation' % team)
|
||||
_update('%s_power_play' % team,
|
||||
'linescore:teams:%s:powerPlay' % team,
|
||||
lambda x: pp_strength if x and pp_strength != 'Even' else '')
|
||||
_update('%s_empty_net' % team,
|
||||
'linescore:teams:%s:goaliePulled' % team,
|
||||
lambda x: self.empty_net if x else '')
|
||||
|
||||
_update('status',
|
||||
'status:abstractGameState',
|
||||
lambda x: x.lower().replace(' ', '_'))
|
||||
|
||||
if ret['status'] == 'live':
|
||||
ret['status'] = 'in_progress'
|
||||
elif ret['status'] == 'final':
|
||||
_update('overtime',
|
||||
'linescore:currentPeriodOrdinal',
|
||||
lambda x: x if 'OT' in x or x == 'SO' else '')
|
||||
elif ret['status'] != 'in_progress':
|
||||
ret['status'] = 'pregame'
|
||||
|
||||
# Game time is in UTC, ISO format, thank the FSM
|
||||
# Ex. 2016-04-02T17:00:00Z
|
||||
game_time_str = game.get('gameDate', '')
|
||||
try:
|
||||
game_time = datetime.strptime(game_time_str, '%Y-%m-%dT%H:%M:%SZ')
|
||||
except ValueError as exc:
|
||||
# Log when the date retrieved from the API return doesn't match the
|
||||
# expected format (to help troubleshoot API changes), and set an
|
||||
# actual datetime so format strings work as expected. The times
|
||||
# will all be wrong, but the logging here will help us make the
|
||||
# necessary changes to adapt to any API changes.
|
||||
self.logger.error(
|
||||
'Error encountered determining %s game time for game %s:',
|
||||
self.__class__.__name__,
|
||||
game['id'],
|
||||
exc_info=True
|
||||
)
|
||||
game_time = datetime.datetime(1970, 1, 1)
|
||||
|
||||
ret['start_time'] = pytz.utc.localize(game_time).astimezone()
|
||||
|
||||
self.logger.debug('Returned %s formatter data: %s',
|
||||
self.__class__.__name__, ret)
|
||||
|
||||
return ret
|
Loading…
Reference in New Issue
Block a user