Add module to display sports scores
This is a pluggable module which supports multiple backends. The first backend displays MLB scores, with more planned (NHL to start).
This commit is contained in:
parent
8aeaab7fa5
commit
afdcf32388
@ -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
|
318
i3pystatus/scores/mlb.py
Normal file
318
i3pystatus/scores/mlb.py
Normal file
@ -0,0 +1,318 @@
|
||||
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'] == '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
|
Loading…
Reference in New Issue
Block a user