diff --git a/plugins.json b/plugins.json index 164dcb84..d2dfe380 100644 --- a/plugins.json +++ b/plugins.json @@ -76,7 +76,7 @@ "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "1.20.3" + "latest_version": "1.21.0" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "1.8.3" + "latest_version": "1.9.0" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "2.9.3" + "latest_version": "2.10.0" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "1.5.3", + "latest_version": "1.6.0", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "1.5.2", + "latest_version": "1.6.0", "icon": "fas fa-baseball-ball" }, { @@ -1023,7 +1023,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.1.2", + "latest_version": "1.2.0", "last_updated": "2026-07-31" }, { @@ -1070,7 +1070,7 @@ "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "1.1.2" + "latest_version": "1.2.0" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/afl_favorite_check.py b/plugins/afl-scoreboard/afl_favorite_check.py new file mode 100644 index 00000000..37bbda4a --- /dev/null +++ b/plugins/afl-scoreboard/afl_favorite_check.py @@ -0,0 +1,308 @@ +""" +Explain an empty screen: a wrong team code, or a season that has not started. + +Favourite teams are matched by exact ESPN abbreviation, so a plausible-looking +code silently matches nothing and the plugin shows an empty screen with no hint +that the code is at fault. The codes are not always guessable — ESPN calls +Alabama ``ALA`` rather than ``BAMA``, and Golden State ``GS`` rather than +``GSW``. Between seasons a perfectly correct code produces the same empty +screen for a completely different reason, and the two were indistinguishable +from the logs. + +This module is diagnostics only. It runs on a daemon thread, once per league per +process, and every failure is swallowed: it must never delay a frame or change +what is displayed. +""" + +import difflib +import logging +import re +import threading +from datetime import datetime, timezone +from typing import Dict, Iterable, Optional, Tuple + +TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/teams?limit=1000" +SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/scoreboard" +REQUEST_TIMEOUT = 15 + + +class FavoriteTeamCheck: + """ + Validates configured favourite team codes against ESPN, and says so in the log. + + ``leagues`` maps the plugin's own league key to a + ``(human readable name, ESPN sport/league path)`` pair, e.g. + ``{'nhl': ('NHL', 'hockey/nhl')}``. + """ + + # How far out the next fixture has to be before it is worth mentioning. + # An off day or two is normal mid-season and saying so would just be noise. + GAP_DAYS = 3 + + def __init__(self, logger: Optional[logging.Logger], + leagues: Dict[str, Tuple[str, str]]) -> None: + self.logger = logger or logging.getLogger(__name__) + self.leagues = leagues + self._checked = set() + self._lock = threading.Lock() + + def reset(self) -> None: + """Re-check on the next call, e.g. after the user edits the config.""" + with self._lock: + self._checked.clear() + + def schedule(self, league_key: str, favorites: Iterable[str]) -> None: + """Check one league in the background, at most once per process.""" + try: + favorites = [str(f) for f in (favorites or []) if str(f).strip()] + if not favorites or league_key not in self.leagues: + return + with self._lock: + if league_key in self._checked: + return + self._checked.add(league_key) + threading.Thread( + target=self._run, args=(league_key, favorites), + name="favorite-team-check", daemon=True, + ).start() + except Exception: + pass # A diagnostic must never be the reason an update fails. + + def _run(self, league_key: str, favorites) -> None: + try: + self._check(league_key, favorites) + except Exception as exc: + self.logger.debug("Favorite team check failed for %s: %s", + league_key, exc) + + def _check(self, league_key: str, favorites) -> None: + name, path = self.leagues[league_key] + + try: + teams = self._fetch_teams(path) + except Exception as exc: + self.logger.debug("Could not verify %s favorite teams: %s", name, exc) + return + if not teams: + # Some ESPN endpoints (college lacrosse) return no teams at all. + # Nothing can be concluded, so say nothing. + return + + # Dynamic groups like AP_TOP_25 are expanded elsewhere; they are not + # team codes and must not be reported as bad ones. + codes = [f for f in favorites if not self._is_dynamic(f)] + recognised = [f for f in codes if f in teams] + unknown = [f for f in codes if f not in teams] + + for code in unknown: + self.logger.warning( + "%s favorite team %r is not a %s team code.%s " + "Every code this league accepts is listed at %s.", + name, code, name, self._suggest(code, teams), + TEAMS_URL.format(path=path), + ) + + if codes and not recognised: + self.logger.warning( + "%s has no recognised favorite teams, so nothing will be shown " + "for it. Codes must be ESPN abbreviations, e.g. %s.", + name, ", ".join("{} ({})".format(a, n) + for a, n in list(sorted(teams.items()))[:3]), + ) + return + + if not recognised: + return + + # Codes are fine, so check the other cause of an empty screen. + try: + note = self._schedule_note(path) + except Exception as exc: + self.logger.debug("Could not check the %s schedule: %s", name, exc) + return + + if note: + self.logger.info( + "%s favorite teams %s look correct, but %s. An empty display " + "until then is expected, not a configuration problem.", + name, ", ".join(recognised), note, + ) + else: + self.logger.info("%s favorite teams recognised: %s", + name, ", ".join(recognised)) + + @staticmethod + def _is_dynamic(code: str) -> bool: + upper = (code or "").strip().upper() + return upper.startswith("AP_") or upper.startswith("TOP_") or "TOP_" in upper + + @staticmethod + def _fetch_teams(path: str) -> Dict[str, str]: + """ESPN's {abbreviation: display name} for a league. + + ``limit=1000`` is required: the default page size truncates the NCAA + responses to roughly half their teams, which makes valid codes look wrong. + """ + import requests + + payload = requests.get(TEAMS_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + entries = payload['sports'][0]['leagues'][0]['teams'] + return { + t['team']['abbreviation']: t['team']['displayName'] + for t in entries if t.get('team', {}).get('abbreviation') + } + + @classmethod + def _schedule_note(cls, path: str) -> Optional[str]: + """ + Why the league has nothing to show, as a clause, or ``None`` if it does. + + Two things make this harder than reading ``events``: + + * An out-of-season league does not come back empty. ESPN rolls the + scoreboard forward to the next day that has fixtures, so in July the + NHL endpoint returns seven September games. Emptiness cannot be the + signal; the date of those games is, and it is more useful anyway. + * A *finished* season rolls nowhere and returns its last game instead, + months in the past — so dates have to be filtered to the future + before the soonest one means anything. + """ + import requests + + payload = requests.get(SCOREBOARD_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + + dates = [cls._parse_date(e.get('date')) + for e in payload.get('events') or []] + for entry in (payload.get('leagues') or [{}])[0].get('calendar') or []: + dates.append(cls._parse_date( + entry if isinstance(entry, str) else entry.get('startDate'))) + + # Count the last day as current, rather than filtering on "later than + # right now": a game that began a few hours ago still means the league + # has something on, and dropping it would report a live slate as a + # finished season. A day's grace also keeps this correct whatever the + # user's timezone, since these timestamps are UTC. + now = datetime.now(timezone.utc) + upcoming = sorted(d for d in dates if d and (now - d).days < 1) + if not upcoming: + if not any(dates): + return None # Nothing published either way; draw no conclusion. + return ("the season has finished and the next one's fixtures are " + "not published yet") + + # A day or two out is just an off day, and saying so would be noise. + if (upcoming[0] - now).days < cls.GAP_DAYS: + return None + return "the league has nothing on until {}".format( + upcoming[0].strftime('%d %B %Y')) + + @staticmethod + def _parse_date(raw) -> Optional[datetime]: + if not raw or not isinstance(raw, str): + return None + try: + return datetime.fromisoformat(raw.replace('Z', '+00:00')) + except ValueError: + return None + + @classmethod + def _suggest(cls, code: str, teams: Dict[str, str]) -> str: + """Nearest matching code for a typo, as a ready-to-log clause.""" + upper = (code or "").strip().upper() + if not upper or code in teams: + return "" + + # Right code, wrong case — matching is case-sensitive. Guard on the case + # actually differing, so a valid code never draws this message. + for abbr in teams: + if abbr.upper() == upper: + return " Codes are case-sensitive; use {!r} ({}).".format( + abbr, teams[abbr]) + + ranked = cls._rank(upper, (a for a, n in teams.items() + if cls._abbreviates(upper, n)), teams) + if not ranked: + # Nicknames are often a fragment of a word rather than its initials: + # 'BAMA' sits inside 'Alabama' but abbreviates nothing in it. Require + # three characters, since shorter fragments match far too much. + if len(upper) >= 3: + ranked = cls._rank( + upper, + (a for a, n in teams.items() + if any(upper in w for w in cls._words(n))), + teams) + + if len(ranked) == 1: + return " Closest match is {!r} ({}).".format( + ranked[0], teams[ranked[0]]) + if ranked: + return " Did you mean {}?".format(", ".join( + "{!r} ({})".format(a, teams[a]) for a in ranked[:3])) + + # Otherwise fall back to similarity, against names before codes: a name + # gives more characters to compare and so produces fewer ties. + names = {n.upper(): a for a, n in teams.items()} + hits = difflib.get_close_matches(upper, list(names), n=1, cutoff=0.6) + if hits: + abbr = names[hits[0]] + return " Closest match is {!r} ({}).".format(abbr, teams[abbr]) + + code_hits = difflib.get_close_matches(upper, list(teams), n=1, cutoff=0.6) + if code_hits: + return " Closest match is {!r} ({}).".format( + code_hits[0], teams[code_hits[0]]) + return "" + + @staticmethod + def _words(name: str): + return [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + @classmethod + def _rank(cls, code: str, candidates, teams: Dict[str, str]): + """ + Order candidate codes best-first. + + A code that picks up the *first* word of the name wins, because that is + how people shorten team names: 'SCAR' for South Carolina starts at + 'South', whereas for Rutgers Scarlet Knights it starts mid-name. Without + this the tie is broken alphabetically and the obvious answer can land + third in the list. + """ + def key(abbr): + words = cls._words(teams.get(abbr, '')) + first_word_hit = bool(words) and words[0].startswith(code[:1]) + return (not first_word_hit, len(abbr), abbr) + + return sorted(set(candidates), key=key) + + @staticmethod + def _abbreviates(code: str, name: str) -> bool: + """ + Whether ``code`` reads as an abbreviation of ``name``. + + Each part of the code must be a prefix of one of the name's words, taken + in order — which is how people actually shorten team names. Plain string + similarity is no use for three-letter codes: 'MUN' scores identically + against 'MAN' and 'SUN', so Manchester United and Sunderland tie and the + suggestion is a coin flip. This rule separates them, because 'MUN' + splits as M-anchester UN-ited while Sunderland has no word starting M. + """ + words = [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + def consume(rest, remaining): + if not rest: + return True + if not remaining: + return False + head, tail = remaining[0], remaining[1:] + # Skip this word entirely, as in "Manchester United" -> "UTD". + if consume(rest, tail): + return True + for size in range(1, min(len(rest), len(head)) + 1): + if head.startswith(rest[:size]) and consume(rest[size:], tail): + return True + return False + + return consume((code or "").strip().upper(), words) diff --git a/plugins/afl-scoreboard/manager.py b/plugins/afl-scoreboard/manager.py index da3cdfef..588f1d3b 100644 --- a/plugins/afl-scoreboard/manager.py +++ b/plugins/afl-scoreboard/manager.py @@ -53,6 +53,11 @@ from afl_managers import create_afl_managers from afl_timezone import resolve_timezone_name +from afl_favorite_check import FavoriteTeamCheck + +# Which ESPN endpoint backs the league, for the favorite-team diagnostic. +FAVORITE_CHECK_KEY = 'afl' +FAVORITE_CHECK_LEAGUES = {FAVORITE_CHECK_KEY: ('AFL', 'australian-football/afl')} logger = logging.getLogger(__name__) @@ -446,14 +451,45 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: self.logger.info("AFL config updated at runtime - reinitialized.") + # Favorites may have changed, so let the diagnostic report on them again. + checker = getattr(self, "_favorite_check", None) + if checker is not None: + checker.reset() + # ------------------------------------------------------------------ # Update # ------------------------------------------------------------------ + def _check_favorite_teams(self) -> None: + """ + Say why the league is showing nothing. + + A favourite that is not a real ESPN abbreviation matches no game, and so + does a correct one before its season starts; both look like an empty + screen. The check runs in the background, once per process, and never + affects what is displayed. + """ + try: + checker = getattr(self, "_favorite_check", None) + if checker is None: + checker = FavoriteTeamCheck(self.logger, FAVORITE_CHECK_LEAGUES) + self._favorite_check = checker + with self._config_lock: + managers = dict(self._managers) + for mode in ("live", "recent", "upcoming"): + favorites = getattr(managers.get(mode), "favorite_teams", None) + if favorites: + checker.schedule(FAVORITE_CHECK_KEY, favorites) + break + except Exception as exc: + self.logger.debug("Favorite team check skipped: %s", exc) + def update(self) -> None: """Update AFL game data using parallel manager updates.""" if not self.is_enabled: return + self._check_favorite_teams() + with self._config_lock: managers_snapshot = dict(self._managers) diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 20361ebb..62d08278 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "afl-scoreboard", "name": "AFL Scoreboard", - "version": "1.1.2", + "version": "1.2.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,11 @@ "afl_upcoming" ], "versions": [ + { + "released": "2026-08-02", + "version": "1.2.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-08-02", "version": "1.1.2", diff --git a/plugins/baseball-scoreboard/CHANGELOG.md b/plugins/baseball-scoreboard/CHANGELOG.md index d0b24481..ea4ebf2e 100644 --- a/plugins/baseball-scoreboard/CHANGELOG.md +++ b/plugins/baseball-scoreboard/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.21.0] - 2026-08-02 + +### Fixed +- Explain an empty screen instead of leaving the user guessing. A favorite team code that is not a real ESPN abbreviation matched no game and showed nothing, and so did a correct code before its season started - the two were indistinguishable from the logs. The plugin now says which it is, suggests the right code for a near miss (GBP -> GB), and reports when the league's next games are. The check runs in the background, once per league, and cannot affect what is displayed. + ## [1.20.3] - 2026-08-02 ### Fixed diff --git a/plugins/baseball-scoreboard/baseball_favorite_check.py b/plugins/baseball-scoreboard/baseball_favorite_check.py new file mode 100644 index 00000000..37bbda4a --- /dev/null +++ b/plugins/baseball-scoreboard/baseball_favorite_check.py @@ -0,0 +1,308 @@ +""" +Explain an empty screen: a wrong team code, or a season that has not started. + +Favourite teams are matched by exact ESPN abbreviation, so a plausible-looking +code silently matches nothing and the plugin shows an empty screen with no hint +that the code is at fault. The codes are not always guessable — ESPN calls +Alabama ``ALA`` rather than ``BAMA``, and Golden State ``GS`` rather than +``GSW``. Between seasons a perfectly correct code produces the same empty +screen for a completely different reason, and the two were indistinguishable +from the logs. + +This module is diagnostics only. It runs on a daemon thread, once per league per +process, and every failure is swallowed: it must never delay a frame or change +what is displayed. +""" + +import difflib +import logging +import re +import threading +from datetime import datetime, timezone +from typing import Dict, Iterable, Optional, Tuple + +TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/teams?limit=1000" +SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/scoreboard" +REQUEST_TIMEOUT = 15 + + +class FavoriteTeamCheck: + """ + Validates configured favourite team codes against ESPN, and says so in the log. + + ``leagues`` maps the plugin's own league key to a + ``(human readable name, ESPN sport/league path)`` pair, e.g. + ``{'nhl': ('NHL', 'hockey/nhl')}``. + """ + + # How far out the next fixture has to be before it is worth mentioning. + # An off day or two is normal mid-season and saying so would just be noise. + GAP_DAYS = 3 + + def __init__(self, logger: Optional[logging.Logger], + leagues: Dict[str, Tuple[str, str]]) -> None: + self.logger = logger or logging.getLogger(__name__) + self.leagues = leagues + self._checked = set() + self._lock = threading.Lock() + + def reset(self) -> None: + """Re-check on the next call, e.g. after the user edits the config.""" + with self._lock: + self._checked.clear() + + def schedule(self, league_key: str, favorites: Iterable[str]) -> None: + """Check one league in the background, at most once per process.""" + try: + favorites = [str(f) for f in (favorites or []) if str(f).strip()] + if not favorites or league_key not in self.leagues: + return + with self._lock: + if league_key in self._checked: + return + self._checked.add(league_key) + threading.Thread( + target=self._run, args=(league_key, favorites), + name="favorite-team-check", daemon=True, + ).start() + except Exception: + pass # A diagnostic must never be the reason an update fails. + + def _run(self, league_key: str, favorites) -> None: + try: + self._check(league_key, favorites) + except Exception as exc: + self.logger.debug("Favorite team check failed for %s: %s", + league_key, exc) + + def _check(self, league_key: str, favorites) -> None: + name, path = self.leagues[league_key] + + try: + teams = self._fetch_teams(path) + except Exception as exc: + self.logger.debug("Could not verify %s favorite teams: %s", name, exc) + return + if not teams: + # Some ESPN endpoints (college lacrosse) return no teams at all. + # Nothing can be concluded, so say nothing. + return + + # Dynamic groups like AP_TOP_25 are expanded elsewhere; they are not + # team codes and must not be reported as bad ones. + codes = [f for f in favorites if not self._is_dynamic(f)] + recognised = [f for f in codes if f in teams] + unknown = [f for f in codes if f not in teams] + + for code in unknown: + self.logger.warning( + "%s favorite team %r is not a %s team code.%s " + "Every code this league accepts is listed at %s.", + name, code, name, self._suggest(code, teams), + TEAMS_URL.format(path=path), + ) + + if codes and not recognised: + self.logger.warning( + "%s has no recognised favorite teams, so nothing will be shown " + "for it. Codes must be ESPN abbreviations, e.g. %s.", + name, ", ".join("{} ({})".format(a, n) + for a, n in list(sorted(teams.items()))[:3]), + ) + return + + if not recognised: + return + + # Codes are fine, so check the other cause of an empty screen. + try: + note = self._schedule_note(path) + except Exception as exc: + self.logger.debug("Could not check the %s schedule: %s", name, exc) + return + + if note: + self.logger.info( + "%s favorite teams %s look correct, but %s. An empty display " + "until then is expected, not a configuration problem.", + name, ", ".join(recognised), note, + ) + else: + self.logger.info("%s favorite teams recognised: %s", + name, ", ".join(recognised)) + + @staticmethod + def _is_dynamic(code: str) -> bool: + upper = (code or "").strip().upper() + return upper.startswith("AP_") or upper.startswith("TOP_") or "TOP_" in upper + + @staticmethod + def _fetch_teams(path: str) -> Dict[str, str]: + """ESPN's {abbreviation: display name} for a league. + + ``limit=1000`` is required: the default page size truncates the NCAA + responses to roughly half their teams, which makes valid codes look wrong. + """ + import requests + + payload = requests.get(TEAMS_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + entries = payload['sports'][0]['leagues'][0]['teams'] + return { + t['team']['abbreviation']: t['team']['displayName'] + for t in entries if t.get('team', {}).get('abbreviation') + } + + @classmethod + def _schedule_note(cls, path: str) -> Optional[str]: + """ + Why the league has nothing to show, as a clause, or ``None`` if it does. + + Two things make this harder than reading ``events``: + + * An out-of-season league does not come back empty. ESPN rolls the + scoreboard forward to the next day that has fixtures, so in July the + NHL endpoint returns seven September games. Emptiness cannot be the + signal; the date of those games is, and it is more useful anyway. + * A *finished* season rolls nowhere and returns its last game instead, + months in the past — so dates have to be filtered to the future + before the soonest one means anything. + """ + import requests + + payload = requests.get(SCOREBOARD_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + + dates = [cls._parse_date(e.get('date')) + for e in payload.get('events') or []] + for entry in (payload.get('leagues') or [{}])[0].get('calendar') or []: + dates.append(cls._parse_date( + entry if isinstance(entry, str) else entry.get('startDate'))) + + # Count the last day as current, rather than filtering on "later than + # right now": a game that began a few hours ago still means the league + # has something on, and dropping it would report a live slate as a + # finished season. A day's grace also keeps this correct whatever the + # user's timezone, since these timestamps are UTC. + now = datetime.now(timezone.utc) + upcoming = sorted(d for d in dates if d and (now - d).days < 1) + if not upcoming: + if not any(dates): + return None # Nothing published either way; draw no conclusion. + return ("the season has finished and the next one's fixtures are " + "not published yet") + + # A day or two out is just an off day, and saying so would be noise. + if (upcoming[0] - now).days < cls.GAP_DAYS: + return None + return "the league has nothing on until {}".format( + upcoming[0].strftime('%d %B %Y')) + + @staticmethod + def _parse_date(raw) -> Optional[datetime]: + if not raw or not isinstance(raw, str): + return None + try: + return datetime.fromisoformat(raw.replace('Z', '+00:00')) + except ValueError: + return None + + @classmethod + def _suggest(cls, code: str, teams: Dict[str, str]) -> str: + """Nearest matching code for a typo, as a ready-to-log clause.""" + upper = (code or "").strip().upper() + if not upper or code in teams: + return "" + + # Right code, wrong case — matching is case-sensitive. Guard on the case + # actually differing, so a valid code never draws this message. + for abbr in teams: + if abbr.upper() == upper: + return " Codes are case-sensitive; use {!r} ({}).".format( + abbr, teams[abbr]) + + ranked = cls._rank(upper, (a for a, n in teams.items() + if cls._abbreviates(upper, n)), teams) + if not ranked: + # Nicknames are often a fragment of a word rather than its initials: + # 'BAMA' sits inside 'Alabama' but abbreviates nothing in it. Require + # three characters, since shorter fragments match far too much. + if len(upper) >= 3: + ranked = cls._rank( + upper, + (a for a, n in teams.items() + if any(upper in w for w in cls._words(n))), + teams) + + if len(ranked) == 1: + return " Closest match is {!r} ({}).".format( + ranked[0], teams[ranked[0]]) + if ranked: + return " Did you mean {}?".format(", ".join( + "{!r} ({})".format(a, teams[a]) for a in ranked[:3])) + + # Otherwise fall back to similarity, against names before codes: a name + # gives more characters to compare and so produces fewer ties. + names = {n.upper(): a for a, n in teams.items()} + hits = difflib.get_close_matches(upper, list(names), n=1, cutoff=0.6) + if hits: + abbr = names[hits[0]] + return " Closest match is {!r} ({}).".format(abbr, teams[abbr]) + + code_hits = difflib.get_close_matches(upper, list(teams), n=1, cutoff=0.6) + if code_hits: + return " Closest match is {!r} ({}).".format( + code_hits[0], teams[code_hits[0]]) + return "" + + @staticmethod + def _words(name: str): + return [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + @classmethod + def _rank(cls, code: str, candidates, teams: Dict[str, str]): + """ + Order candidate codes best-first. + + A code that picks up the *first* word of the name wins, because that is + how people shorten team names: 'SCAR' for South Carolina starts at + 'South', whereas for Rutgers Scarlet Knights it starts mid-name. Without + this the tie is broken alphabetically and the obvious answer can land + third in the list. + """ + def key(abbr): + words = cls._words(teams.get(abbr, '')) + first_word_hit = bool(words) and words[0].startswith(code[:1]) + return (not first_word_hit, len(abbr), abbr) + + return sorted(set(candidates), key=key) + + @staticmethod + def _abbreviates(code: str, name: str) -> bool: + """ + Whether ``code`` reads as an abbreviation of ``name``. + + Each part of the code must be a prefix of one of the name's words, taken + in order — which is how people actually shorten team names. Plain string + similarity is no use for three-letter codes: 'MUN' scores identically + against 'MAN' and 'SUN', so Manchester United and Sunderland tie and the + suggestion is a coin flip. This rule separates them, because 'MUN' + splits as M-anchester UN-ited while Sunderland has no word starting M. + """ + words = [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + def consume(rest, remaining): + if not rest: + return True + if not remaining: + return False + head, tail = remaining[0], remaining[1:] + # Skip this word entirely, as in "Manchester United" -> "UTD". + if consume(rest, tail): + return True + for size in range(1, min(len(rest), len(head)) + 1): + if head.startswith(rest[:size]) and consume(rest[size:], tail): + return True + return False + + return consume((code or "").strip().upper(), words) diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 496033e0..ce4ddb12 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -51,6 +51,14 @@ ) from milb_managers import MiLBLiveManager, MiLBRecentManager, MiLBUpcomingManager from baseball_timezone import resolve_timezone_name +from baseball_favorite_check import FavoriteTeamCheck + +# Which ESPN endpoint backs each league, for the favorite-team diagnostic. +FAVORITE_CHECK_LEAGUES = { + 'mlb': ('MLB', 'baseball/mlb'), + 'ncaa_baseball': ('NCAA Baseball', 'baseball/college-baseball'), + 'milb': ('MiLB', 'baseball/milb'), +} # Import scroll display components try: @@ -342,6 +350,11 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: self.mlb_enabled, self.milb_enabled, self.ncaa_baseball_enabled, self.modes, ) + # Favorites may have changed, so let the diagnostic report on them again. + checker = getattr(self, "_favorite_check", None) + if checker is not None: + checker.reset() + def _cleanup_managers(self) -> None: """Close HTTP sessions / clear caches on the current league managers.""" for attr in ( @@ -1079,11 +1092,39 @@ def _ensure_manager_updated(self, manager) -> None: except Exception as exc: self.logger.debug(f"Auto-refresh failed for manager {manager}: {exc}") + def _check_favorite_teams(self) -> None: + """ + Say why an enabled league is showing nothing. + + A favourite that is not a real ESPN abbreviation matches no game, and so + does a correct one before its season starts; both look like an empty + screen. The check runs in the background, once per league per process, + and never affects what is displayed. + """ + try: + checker = getattr(self, "_favorite_check", None) + if checker is None: + checker = FavoriteTeamCheck(self.logger, FAVORITE_CHECK_LEAGUES) + self._favorite_check = checker + for league in FAVORITE_CHECK_LEAGUES: + if not getattr(self, "{}_enabled".format(league), False): + continue + for mode in ("live", "recent", "upcoming"): + manager = getattr(self, "{}_{}".format(league, mode), None) + favorites = getattr(manager, "favorite_teams", None) + if favorites: + checker.schedule(league, favorites) + break + except Exception as exc: + self.logger.debug("Favorite team check skipped: %s", exc) + def update(self) -> None: """Update baseball game data using parallel manager updates.""" if not self.is_enabled: return + self._check_favorite_teams() + from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError # Collect all enabled managers (use getattr to guard against partial init) diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 61fa6c1d..13f48e66 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.20.3", + "version": "1.21.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -30,6 +30,11 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-08-02", + "version": "1.21.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-08-02", "version": "1.20.3", diff --git a/plugins/basketball-scoreboard/basketball_favorite_check.py b/plugins/basketball-scoreboard/basketball_favorite_check.py new file mode 100644 index 00000000..37bbda4a --- /dev/null +++ b/plugins/basketball-scoreboard/basketball_favorite_check.py @@ -0,0 +1,308 @@ +""" +Explain an empty screen: a wrong team code, or a season that has not started. + +Favourite teams are matched by exact ESPN abbreviation, so a plausible-looking +code silently matches nothing and the plugin shows an empty screen with no hint +that the code is at fault. The codes are not always guessable — ESPN calls +Alabama ``ALA`` rather than ``BAMA``, and Golden State ``GS`` rather than +``GSW``. Between seasons a perfectly correct code produces the same empty +screen for a completely different reason, and the two were indistinguishable +from the logs. + +This module is diagnostics only. It runs on a daemon thread, once per league per +process, and every failure is swallowed: it must never delay a frame or change +what is displayed. +""" + +import difflib +import logging +import re +import threading +from datetime import datetime, timezone +from typing import Dict, Iterable, Optional, Tuple + +TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/teams?limit=1000" +SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/scoreboard" +REQUEST_TIMEOUT = 15 + + +class FavoriteTeamCheck: + """ + Validates configured favourite team codes against ESPN, and says so in the log. + + ``leagues`` maps the plugin's own league key to a + ``(human readable name, ESPN sport/league path)`` pair, e.g. + ``{'nhl': ('NHL', 'hockey/nhl')}``. + """ + + # How far out the next fixture has to be before it is worth mentioning. + # An off day or two is normal mid-season and saying so would just be noise. + GAP_DAYS = 3 + + def __init__(self, logger: Optional[logging.Logger], + leagues: Dict[str, Tuple[str, str]]) -> None: + self.logger = logger or logging.getLogger(__name__) + self.leagues = leagues + self._checked = set() + self._lock = threading.Lock() + + def reset(self) -> None: + """Re-check on the next call, e.g. after the user edits the config.""" + with self._lock: + self._checked.clear() + + def schedule(self, league_key: str, favorites: Iterable[str]) -> None: + """Check one league in the background, at most once per process.""" + try: + favorites = [str(f) for f in (favorites or []) if str(f).strip()] + if not favorites or league_key not in self.leagues: + return + with self._lock: + if league_key in self._checked: + return + self._checked.add(league_key) + threading.Thread( + target=self._run, args=(league_key, favorites), + name="favorite-team-check", daemon=True, + ).start() + except Exception: + pass # A diagnostic must never be the reason an update fails. + + def _run(self, league_key: str, favorites) -> None: + try: + self._check(league_key, favorites) + except Exception as exc: + self.logger.debug("Favorite team check failed for %s: %s", + league_key, exc) + + def _check(self, league_key: str, favorites) -> None: + name, path = self.leagues[league_key] + + try: + teams = self._fetch_teams(path) + except Exception as exc: + self.logger.debug("Could not verify %s favorite teams: %s", name, exc) + return + if not teams: + # Some ESPN endpoints (college lacrosse) return no teams at all. + # Nothing can be concluded, so say nothing. + return + + # Dynamic groups like AP_TOP_25 are expanded elsewhere; they are not + # team codes and must not be reported as bad ones. + codes = [f for f in favorites if not self._is_dynamic(f)] + recognised = [f for f in codes if f in teams] + unknown = [f for f in codes if f not in teams] + + for code in unknown: + self.logger.warning( + "%s favorite team %r is not a %s team code.%s " + "Every code this league accepts is listed at %s.", + name, code, name, self._suggest(code, teams), + TEAMS_URL.format(path=path), + ) + + if codes and not recognised: + self.logger.warning( + "%s has no recognised favorite teams, so nothing will be shown " + "for it. Codes must be ESPN abbreviations, e.g. %s.", + name, ", ".join("{} ({})".format(a, n) + for a, n in list(sorted(teams.items()))[:3]), + ) + return + + if not recognised: + return + + # Codes are fine, so check the other cause of an empty screen. + try: + note = self._schedule_note(path) + except Exception as exc: + self.logger.debug("Could not check the %s schedule: %s", name, exc) + return + + if note: + self.logger.info( + "%s favorite teams %s look correct, but %s. An empty display " + "until then is expected, not a configuration problem.", + name, ", ".join(recognised), note, + ) + else: + self.logger.info("%s favorite teams recognised: %s", + name, ", ".join(recognised)) + + @staticmethod + def _is_dynamic(code: str) -> bool: + upper = (code or "").strip().upper() + return upper.startswith("AP_") or upper.startswith("TOP_") or "TOP_" in upper + + @staticmethod + def _fetch_teams(path: str) -> Dict[str, str]: + """ESPN's {abbreviation: display name} for a league. + + ``limit=1000`` is required: the default page size truncates the NCAA + responses to roughly half their teams, which makes valid codes look wrong. + """ + import requests + + payload = requests.get(TEAMS_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + entries = payload['sports'][0]['leagues'][0]['teams'] + return { + t['team']['abbreviation']: t['team']['displayName'] + for t in entries if t.get('team', {}).get('abbreviation') + } + + @classmethod + def _schedule_note(cls, path: str) -> Optional[str]: + """ + Why the league has nothing to show, as a clause, or ``None`` if it does. + + Two things make this harder than reading ``events``: + + * An out-of-season league does not come back empty. ESPN rolls the + scoreboard forward to the next day that has fixtures, so in July the + NHL endpoint returns seven September games. Emptiness cannot be the + signal; the date of those games is, and it is more useful anyway. + * A *finished* season rolls nowhere and returns its last game instead, + months in the past — so dates have to be filtered to the future + before the soonest one means anything. + """ + import requests + + payload = requests.get(SCOREBOARD_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + + dates = [cls._parse_date(e.get('date')) + for e in payload.get('events') or []] + for entry in (payload.get('leagues') or [{}])[0].get('calendar') or []: + dates.append(cls._parse_date( + entry if isinstance(entry, str) else entry.get('startDate'))) + + # Count the last day as current, rather than filtering on "later than + # right now": a game that began a few hours ago still means the league + # has something on, and dropping it would report a live slate as a + # finished season. A day's grace also keeps this correct whatever the + # user's timezone, since these timestamps are UTC. + now = datetime.now(timezone.utc) + upcoming = sorted(d for d in dates if d and (now - d).days < 1) + if not upcoming: + if not any(dates): + return None # Nothing published either way; draw no conclusion. + return ("the season has finished and the next one's fixtures are " + "not published yet") + + # A day or two out is just an off day, and saying so would be noise. + if (upcoming[0] - now).days < cls.GAP_DAYS: + return None + return "the league has nothing on until {}".format( + upcoming[0].strftime('%d %B %Y')) + + @staticmethod + def _parse_date(raw) -> Optional[datetime]: + if not raw or not isinstance(raw, str): + return None + try: + return datetime.fromisoformat(raw.replace('Z', '+00:00')) + except ValueError: + return None + + @classmethod + def _suggest(cls, code: str, teams: Dict[str, str]) -> str: + """Nearest matching code for a typo, as a ready-to-log clause.""" + upper = (code or "").strip().upper() + if not upper or code in teams: + return "" + + # Right code, wrong case — matching is case-sensitive. Guard on the case + # actually differing, so a valid code never draws this message. + for abbr in teams: + if abbr.upper() == upper: + return " Codes are case-sensitive; use {!r} ({}).".format( + abbr, teams[abbr]) + + ranked = cls._rank(upper, (a for a, n in teams.items() + if cls._abbreviates(upper, n)), teams) + if not ranked: + # Nicknames are often a fragment of a word rather than its initials: + # 'BAMA' sits inside 'Alabama' but abbreviates nothing in it. Require + # three characters, since shorter fragments match far too much. + if len(upper) >= 3: + ranked = cls._rank( + upper, + (a for a, n in teams.items() + if any(upper in w for w in cls._words(n))), + teams) + + if len(ranked) == 1: + return " Closest match is {!r} ({}).".format( + ranked[0], teams[ranked[0]]) + if ranked: + return " Did you mean {}?".format(", ".join( + "{!r} ({})".format(a, teams[a]) for a in ranked[:3])) + + # Otherwise fall back to similarity, against names before codes: a name + # gives more characters to compare and so produces fewer ties. + names = {n.upper(): a for a, n in teams.items()} + hits = difflib.get_close_matches(upper, list(names), n=1, cutoff=0.6) + if hits: + abbr = names[hits[0]] + return " Closest match is {!r} ({}).".format(abbr, teams[abbr]) + + code_hits = difflib.get_close_matches(upper, list(teams), n=1, cutoff=0.6) + if code_hits: + return " Closest match is {!r} ({}).".format( + code_hits[0], teams[code_hits[0]]) + return "" + + @staticmethod + def _words(name: str): + return [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + @classmethod + def _rank(cls, code: str, candidates, teams: Dict[str, str]): + """ + Order candidate codes best-first. + + A code that picks up the *first* word of the name wins, because that is + how people shorten team names: 'SCAR' for South Carolina starts at + 'South', whereas for Rutgers Scarlet Knights it starts mid-name. Without + this the tie is broken alphabetically and the obvious answer can land + third in the list. + """ + def key(abbr): + words = cls._words(teams.get(abbr, '')) + first_word_hit = bool(words) and words[0].startswith(code[:1]) + return (not first_word_hit, len(abbr), abbr) + + return sorted(set(candidates), key=key) + + @staticmethod + def _abbreviates(code: str, name: str) -> bool: + """ + Whether ``code`` reads as an abbreviation of ``name``. + + Each part of the code must be a prefix of one of the name's words, taken + in order — which is how people actually shorten team names. Plain string + similarity is no use for three-letter codes: 'MUN' scores identically + against 'MAN' and 'SUN', so Manchester United and Sunderland tie and the + suggestion is a coin flip. This rule separates them, because 'MUN' + splits as M-anchester UN-ited while Sunderland has no word starting M. + """ + words = [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + def consume(rest, remaining): + if not rest: + return True + if not remaining: + return False + head, tail = remaining[0], remaining[1:] + # Skip this word entirely, as in "Manchester United" -> "UTD". + if consume(rest, tail): + return True + for size in range(1, min(len(rest), len(head)) + 1): + if head.startswith(rest[:size]) and consume(rest[size:], tail): + return True + return False + + return consume((code or "").strip().upper(), words) diff --git a/plugins/basketball-scoreboard/manager.py b/plugins/basketball-scoreboard/manager.py index 63cb7f3b..161635f0 100644 --- a/plugins/basketball-scoreboard/manager.py +++ b/plugins/basketball-scoreboard/manager.py @@ -56,6 +56,15 @@ ) from basketball_timezone import resolve_timezone_name +from basketball_favorite_check import FavoriteTeamCheck + +# Which ESPN endpoint backs each league, for the favorite-team diagnostic. +FAVORITE_CHECK_LEAGUES = { + 'nba': ('NBA', 'basketball/nba'), + 'wnba': ('WNBA', 'basketball/wnba'), + 'ncaam': ("NCAA Men's Basketball", 'basketball/mens-college-basketball'), + 'ncaaw': ("NCAA Women's Basketball", 'basketball/womens-college-basketball'), +} logger = logging.getLogger(__name__) @@ -944,11 +953,39 @@ def _get_current_manager(self): return None + def _check_favorite_teams(self) -> None: + """ + Say why an enabled league is showing nothing. + + A favourite that is not a real ESPN abbreviation matches no game, and so + does a correct one before its season starts; both look like an empty + screen. The check runs in the background, once per league per process, + and never affects what is displayed. + """ + try: + checker = getattr(self, "_favorite_check", None) + if checker is None: + checker = FavoriteTeamCheck(self.logger, FAVORITE_CHECK_LEAGUES) + self._favorite_check = checker + for league in FAVORITE_CHECK_LEAGUES: + if not getattr(self, "{}_enabled".format(league), False): + continue + for mode in ("live", "recent", "upcoming"): + manager = getattr(self, "{}_{}".format(league, mode), None) + favorites = getattr(manager, "favorite_teams", None) + if favorites: + checker.schedule(league, favorites) + break + except Exception as exc: + self.logger.debug("Favorite team check skipped: %s", exc) + def update(self) -> None: """Update basketball game data using parallel manager updates.""" if not self.is_enabled: return + self._check_favorite_teams() + # Collect all manager update tasks update_tasks = [] diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 67de27a1..f152747c 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.8.3", + "version": "1.9.0", "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", "category": "sports", @@ -18,6 +18,11 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "released": "2026-08-02", + "version": "1.9.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-08-02", "version": "1.8.3", diff --git a/plugins/football-scoreboard/CHANGELOG.md b/plugins/football-scoreboard/CHANGELOG.md index eb0d5fb2..ae2fadcd 100644 --- a/plugins/football-scoreboard/CHANGELOG.md +++ b/plugins/football-scoreboard/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [2.10.0] - 2026-08-02 + +### Fixed +- Explain an empty screen instead of leaving the user guessing. A favorite team code that is not a real ESPN abbreviation matched no game and showed nothing, and so did a correct code before its season started - the two were indistinguishable from the logs. The plugin now says which it is, suggests the right code for a near miss (GBP -> GB), and reports when the league's next games are. The check runs in the background, once per league, and cannot affect what is displayed. + ## [2.9.3] - 2026-08-02 ### Fixed diff --git a/plugins/football-scoreboard/football_favorite_check.py b/plugins/football-scoreboard/football_favorite_check.py new file mode 100644 index 00000000..37bbda4a --- /dev/null +++ b/plugins/football-scoreboard/football_favorite_check.py @@ -0,0 +1,308 @@ +""" +Explain an empty screen: a wrong team code, or a season that has not started. + +Favourite teams are matched by exact ESPN abbreviation, so a plausible-looking +code silently matches nothing and the plugin shows an empty screen with no hint +that the code is at fault. The codes are not always guessable — ESPN calls +Alabama ``ALA`` rather than ``BAMA``, and Golden State ``GS`` rather than +``GSW``. Between seasons a perfectly correct code produces the same empty +screen for a completely different reason, and the two were indistinguishable +from the logs. + +This module is diagnostics only. It runs on a daemon thread, once per league per +process, and every failure is swallowed: it must never delay a frame or change +what is displayed. +""" + +import difflib +import logging +import re +import threading +from datetime import datetime, timezone +from typing import Dict, Iterable, Optional, Tuple + +TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/teams?limit=1000" +SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/scoreboard" +REQUEST_TIMEOUT = 15 + + +class FavoriteTeamCheck: + """ + Validates configured favourite team codes against ESPN, and says so in the log. + + ``leagues`` maps the plugin's own league key to a + ``(human readable name, ESPN sport/league path)`` pair, e.g. + ``{'nhl': ('NHL', 'hockey/nhl')}``. + """ + + # How far out the next fixture has to be before it is worth mentioning. + # An off day or two is normal mid-season and saying so would just be noise. + GAP_DAYS = 3 + + def __init__(self, logger: Optional[logging.Logger], + leagues: Dict[str, Tuple[str, str]]) -> None: + self.logger = logger or logging.getLogger(__name__) + self.leagues = leagues + self._checked = set() + self._lock = threading.Lock() + + def reset(self) -> None: + """Re-check on the next call, e.g. after the user edits the config.""" + with self._lock: + self._checked.clear() + + def schedule(self, league_key: str, favorites: Iterable[str]) -> None: + """Check one league in the background, at most once per process.""" + try: + favorites = [str(f) for f in (favorites or []) if str(f).strip()] + if not favorites or league_key not in self.leagues: + return + with self._lock: + if league_key in self._checked: + return + self._checked.add(league_key) + threading.Thread( + target=self._run, args=(league_key, favorites), + name="favorite-team-check", daemon=True, + ).start() + except Exception: + pass # A diagnostic must never be the reason an update fails. + + def _run(self, league_key: str, favorites) -> None: + try: + self._check(league_key, favorites) + except Exception as exc: + self.logger.debug("Favorite team check failed for %s: %s", + league_key, exc) + + def _check(self, league_key: str, favorites) -> None: + name, path = self.leagues[league_key] + + try: + teams = self._fetch_teams(path) + except Exception as exc: + self.logger.debug("Could not verify %s favorite teams: %s", name, exc) + return + if not teams: + # Some ESPN endpoints (college lacrosse) return no teams at all. + # Nothing can be concluded, so say nothing. + return + + # Dynamic groups like AP_TOP_25 are expanded elsewhere; they are not + # team codes and must not be reported as bad ones. + codes = [f for f in favorites if not self._is_dynamic(f)] + recognised = [f for f in codes if f in teams] + unknown = [f for f in codes if f not in teams] + + for code in unknown: + self.logger.warning( + "%s favorite team %r is not a %s team code.%s " + "Every code this league accepts is listed at %s.", + name, code, name, self._suggest(code, teams), + TEAMS_URL.format(path=path), + ) + + if codes and not recognised: + self.logger.warning( + "%s has no recognised favorite teams, so nothing will be shown " + "for it. Codes must be ESPN abbreviations, e.g. %s.", + name, ", ".join("{} ({})".format(a, n) + for a, n in list(sorted(teams.items()))[:3]), + ) + return + + if not recognised: + return + + # Codes are fine, so check the other cause of an empty screen. + try: + note = self._schedule_note(path) + except Exception as exc: + self.logger.debug("Could not check the %s schedule: %s", name, exc) + return + + if note: + self.logger.info( + "%s favorite teams %s look correct, but %s. An empty display " + "until then is expected, not a configuration problem.", + name, ", ".join(recognised), note, + ) + else: + self.logger.info("%s favorite teams recognised: %s", + name, ", ".join(recognised)) + + @staticmethod + def _is_dynamic(code: str) -> bool: + upper = (code or "").strip().upper() + return upper.startswith("AP_") or upper.startswith("TOP_") or "TOP_" in upper + + @staticmethod + def _fetch_teams(path: str) -> Dict[str, str]: + """ESPN's {abbreviation: display name} for a league. + + ``limit=1000`` is required: the default page size truncates the NCAA + responses to roughly half their teams, which makes valid codes look wrong. + """ + import requests + + payload = requests.get(TEAMS_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + entries = payload['sports'][0]['leagues'][0]['teams'] + return { + t['team']['abbreviation']: t['team']['displayName'] + for t in entries if t.get('team', {}).get('abbreviation') + } + + @classmethod + def _schedule_note(cls, path: str) -> Optional[str]: + """ + Why the league has nothing to show, as a clause, or ``None`` if it does. + + Two things make this harder than reading ``events``: + + * An out-of-season league does not come back empty. ESPN rolls the + scoreboard forward to the next day that has fixtures, so in July the + NHL endpoint returns seven September games. Emptiness cannot be the + signal; the date of those games is, and it is more useful anyway. + * A *finished* season rolls nowhere and returns its last game instead, + months in the past — so dates have to be filtered to the future + before the soonest one means anything. + """ + import requests + + payload = requests.get(SCOREBOARD_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + + dates = [cls._parse_date(e.get('date')) + for e in payload.get('events') or []] + for entry in (payload.get('leagues') or [{}])[0].get('calendar') or []: + dates.append(cls._parse_date( + entry if isinstance(entry, str) else entry.get('startDate'))) + + # Count the last day as current, rather than filtering on "later than + # right now": a game that began a few hours ago still means the league + # has something on, and dropping it would report a live slate as a + # finished season. A day's grace also keeps this correct whatever the + # user's timezone, since these timestamps are UTC. + now = datetime.now(timezone.utc) + upcoming = sorted(d for d in dates if d and (now - d).days < 1) + if not upcoming: + if not any(dates): + return None # Nothing published either way; draw no conclusion. + return ("the season has finished and the next one's fixtures are " + "not published yet") + + # A day or two out is just an off day, and saying so would be noise. + if (upcoming[0] - now).days < cls.GAP_DAYS: + return None + return "the league has nothing on until {}".format( + upcoming[0].strftime('%d %B %Y')) + + @staticmethod + def _parse_date(raw) -> Optional[datetime]: + if not raw or not isinstance(raw, str): + return None + try: + return datetime.fromisoformat(raw.replace('Z', '+00:00')) + except ValueError: + return None + + @classmethod + def _suggest(cls, code: str, teams: Dict[str, str]) -> str: + """Nearest matching code for a typo, as a ready-to-log clause.""" + upper = (code or "").strip().upper() + if not upper or code in teams: + return "" + + # Right code, wrong case — matching is case-sensitive. Guard on the case + # actually differing, so a valid code never draws this message. + for abbr in teams: + if abbr.upper() == upper: + return " Codes are case-sensitive; use {!r} ({}).".format( + abbr, teams[abbr]) + + ranked = cls._rank(upper, (a for a, n in teams.items() + if cls._abbreviates(upper, n)), teams) + if not ranked: + # Nicknames are often a fragment of a word rather than its initials: + # 'BAMA' sits inside 'Alabama' but abbreviates nothing in it. Require + # three characters, since shorter fragments match far too much. + if len(upper) >= 3: + ranked = cls._rank( + upper, + (a for a, n in teams.items() + if any(upper in w for w in cls._words(n))), + teams) + + if len(ranked) == 1: + return " Closest match is {!r} ({}).".format( + ranked[0], teams[ranked[0]]) + if ranked: + return " Did you mean {}?".format(", ".join( + "{!r} ({})".format(a, teams[a]) for a in ranked[:3])) + + # Otherwise fall back to similarity, against names before codes: a name + # gives more characters to compare and so produces fewer ties. + names = {n.upper(): a for a, n in teams.items()} + hits = difflib.get_close_matches(upper, list(names), n=1, cutoff=0.6) + if hits: + abbr = names[hits[0]] + return " Closest match is {!r} ({}).".format(abbr, teams[abbr]) + + code_hits = difflib.get_close_matches(upper, list(teams), n=1, cutoff=0.6) + if code_hits: + return " Closest match is {!r} ({}).".format( + code_hits[0], teams[code_hits[0]]) + return "" + + @staticmethod + def _words(name: str): + return [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + @classmethod + def _rank(cls, code: str, candidates, teams: Dict[str, str]): + """ + Order candidate codes best-first. + + A code that picks up the *first* word of the name wins, because that is + how people shorten team names: 'SCAR' for South Carolina starts at + 'South', whereas for Rutgers Scarlet Knights it starts mid-name. Without + this the tie is broken alphabetically and the obvious answer can land + third in the list. + """ + def key(abbr): + words = cls._words(teams.get(abbr, '')) + first_word_hit = bool(words) and words[0].startswith(code[:1]) + return (not first_word_hit, len(abbr), abbr) + + return sorted(set(candidates), key=key) + + @staticmethod + def _abbreviates(code: str, name: str) -> bool: + """ + Whether ``code`` reads as an abbreviation of ``name``. + + Each part of the code must be a prefix of one of the name's words, taken + in order — which is how people actually shorten team names. Plain string + similarity is no use for three-letter codes: 'MUN' scores identically + against 'MAN' and 'SUN', so Manchester United and Sunderland tie and the + suggestion is a coin flip. This rule separates them, because 'MUN' + splits as M-anchester UN-ited while Sunderland has no word starting M. + """ + words = [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + def consume(rest, remaining): + if not rest: + return True + if not remaining: + return False + head, tail = remaining[0], remaining[1:] + # Skip this word entirely, as in "Manchester United" -> "UTD". + if consume(rest, tail): + return True + for size in range(1, min(len(rest), len(head)) + 1): + if head.startswith(rest[:size]) and consume(rest[size:], tail): + return True + return False + + return consume((code or "").strip().upper(), words) diff --git a/plugins/football-scoreboard/manager.py b/plugins/football-scoreboard/manager.py index 8d7f2a93..881943f6 100644 --- a/plugins/football-scoreboard/manager.py +++ b/plugins/football-scoreboard/manager.py @@ -59,6 +59,13 @@ SCROLL_AVAILABLE = False from football_timezone import resolve_timezone_name +from football_favorite_check import FavoriteTeamCheck + +# Which ESPN endpoint backs each league, for the favorite-team diagnostic. +FAVORITE_CHECK_LEAGUES = { + 'nfl': ('NFL', 'football/nfl'), + 'ncaa_fb': ('NCAA Football', 'football/college-football'), +} logger = logging.getLogger(__name__) @@ -309,6 +316,11 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: self.nfl_enabled, self.ncaa_fb_enabled, self.modes, ) + # Favorites may have changed, so let the diagnostic report on them again. + checker = getattr(self, "_favorite_check", None) + if checker is not None: + checker.reset() + def _cleanup_managers(self) -> None: """Close HTTP sessions / clear caches on the current league managers.""" for attr in ( @@ -966,11 +978,39 @@ def _ensure_manager_updated(self, manager) -> None: except Exception as exc: self.logger.debug(f"Auto-refresh failed for manager {manager}: {exc}") + def _check_favorite_teams(self) -> None: + """ + Say why an enabled league is showing nothing. + + A favourite that is not a real ESPN abbreviation matches no game, and so + does a correct one before its season starts; both look like an empty + screen. The check runs in the background, once per league per process, + and never affects what is displayed. + """ + try: + checker = getattr(self, "_favorite_check", None) + if checker is None: + checker = FavoriteTeamCheck(self.logger, FAVORITE_CHECK_LEAGUES) + self._favorite_check = checker + for league in FAVORITE_CHECK_LEAGUES: + if not getattr(self, "{}_enabled".format(league), False): + continue + for mode in ("live", "recent", "upcoming"): + manager = getattr(self, "{}_{}".format(league, mode), None) + favorites = getattr(manager, "favorite_teams", None) + if favorites: + checker.schedule(league, favorites) + break + except Exception as exc: + self.logger.debug("Favorite team check skipped: %s", exc) + def update(self) -> None: """Update football game data.""" if not self.is_enabled: return + self._check_favorite_teams() + try: # Update NFL managers if enabled if self.nfl_enabled: diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 0d73af6a..13fa6e53 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "2.9.3", + "version": "2.10.0", "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", "description": "Standalone plugin for live, recent, and upcoming football games across NFL and NCAA Football with real-time scores, down/distance, possession, and game status. Now with organized nested config!", @@ -24,6 +24,11 @@ "ncaa_fb_live" ], "versions": [ + { + "released": "2026-08-02", + "version": "2.10.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-08-02", "version": "2.9.3", diff --git a/plugins/hockey-scoreboard/hockey_favorite_check.py b/plugins/hockey-scoreboard/hockey_favorite_check.py new file mode 100644 index 00000000..37bbda4a --- /dev/null +++ b/plugins/hockey-scoreboard/hockey_favorite_check.py @@ -0,0 +1,308 @@ +""" +Explain an empty screen: a wrong team code, or a season that has not started. + +Favourite teams are matched by exact ESPN abbreviation, so a plausible-looking +code silently matches nothing and the plugin shows an empty screen with no hint +that the code is at fault. The codes are not always guessable — ESPN calls +Alabama ``ALA`` rather than ``BAMA``, and Golden State ``GS`` rather than +``GSW``. Between seasons a perfectly correct code produces the same empty +screen for a completely different reason, and the two were indistinguishable +from the logs. + +This module is diagnostics only. It runs on a daemon thread, once per league per +process, and every failure is swallowed: it must never delay a frame or change +what is displayed. +""" + +import difflib +import logging +import re +import threading +from datetime import datetime, timezone +from typing import Dict, Iterable, Optional, Tuple + +TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/teams?limit=1000" +SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/scoreboard" +REQUEST_TIMEOUT = 15 + + +class FavoriteTeamCheck: + """ + Validates configured favourite team codes against ESPN, and says so in the log. + + ``leagues`` maps the plugin's own league key to a + ``(human readable name, ESPN sport/league path)`` pair, e.g. + ``{'nhl': ('NHL', 'hockey/nhl')}``. + """ + + # How far out the next fixture has to be before it is worth mentioning. + # An off day or two is normal mid-season and saying so would just be noise. + GAP_DAYS = 3 + + def __init__(self, logger: Optional[logging.Logger], + leagues: Dict[str, Tuple[str, str]]) -> None: + self.logger = logger or logging.getLogger(__name__) + self.leagues = leagues + self._checked = set() + self._lock = threading.Lock() + + def reset(self) -> None: + """Re-check on the next call, e.g. after the user edits the config.""" + with self._lock: + self._checked.clear() + + def schedule(self, league_key: str, favorites: Iterable[str]) -> None: + """Check one league in the background, at most once per process.""" + try: + favorites = [str(f) for f in (favorites or []) if str(f).strip()] + if not favorites or league_key not in self.leagues: + return + with self._lock: + if league_key in self._checked: + return + self._checked.add(league_key) + threading.Thread( + target=self._run, args=(league_key, favorites), + name="favorite-team-check", daemon=True, + ).start() + except Exception: + pass # A diagnostic must never be the reason an update fails. + + def _run(self, league_key: str, favorites) -> None: + try: + self._check(league_key, favorites) + except Exception as exc: + self.logger.debug("Favorite team check failed for %s: %s", + league_key, exc) + + def _check(self, league_key: str, favorites) -> None: + name, path = self.leagues[league_key] + + try: + teams = self._fetch_teams(path) + except Exception as exc: + self.logger.debug("Could not verify %s favorite teams: %s", name, exc) + return + if not teams: + # Some ESPN endpoints (college lacrosse) return no teams at all. + # Nothing can be concluded, so say nothing. + return + + # Dynamic groups like AP_TOP_25 are expanded elsewhere; they are not + # team codes and must not be reported as bad ones. + codes = [f for f in favorites if not self._is_dynamic(f)] + recognised = [f for f in codes if f in teams] + unknown = [f for f in codes if f not in teams] + + for code in unknown: + self.logger.warning( + "%s favorite team %r is not a %s team code.%s " + "Every code this league accepts is listed at %s.", + name, code, name, self._suggest(code, teams), + TEAMS_URL.format(path=path), + ) + + if codes and not recognised: + self.logger.warning( + "%s has no recognised favorite teams, so nothing will be shown " + "for it. Codes must be ESPN abbreviations, e.g. %s.", + name, ", ".join("{} ({})".format(a, n) + for a, n in list(sorted(teams.items()))[:3]), + ) + return + + if not recognised: + return + + # Codes are fine, so check the other cause of an empty screen. + try: + note = self._schedule_note(path) + except Exception as exc: + self.logger.debug("Could not check the %s schedule: %s", name, exc) + return + + if note: + self.logger.info( + "%s favorite teams %s look correct, but %s. An empty display " + "until then is expected, not a configuration problem.", + name, ", ".join(recognised), note, + ) + else: + self.logger.info("%s favorite teams recognised: %s", + name, ", ".join(recognised)) + + @staticmethod + def _is_dynamic(code: str) -> bool: + upper = (code or "").strip().upper() + return upper.startswith("AP_") or upper.startswith("TOP_") or "TOP_" in upper + + @staticmethod + def _fetch_teams(path: str) -> Dict[str, str]: + """ESPN's {abbreviation: display name} for a league. + + ``limit=1000`` is required: the default page size truncates the NCAA + responses to roughly half their teams, which makes valid codes look wrong. + """ + import requests + + payload = requests.get(TEAMS_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + entries = payload['sports'][0]['leagues'][0]['teams'] + return { + t['team']['abbreviation']: t['team']['displayName'] + for t in entries if t.get('team', {}).get('abbreviation') + } + + @classmethod + def _schedule_note(cls, path: str) -> Optional[str]: + """ + Why the league has nothing to show, as a clause, or ``None`` if it does. + + Two things make this harder than reading ``events``: + + * An out-of-season league does not come back empty. ESPN rolls the + scoreboard forward to the next day that has fixtures, so in July the + NHL endpoint returns seven September games. Emptiness cannot be the + signal; the date of those games is, and it is more useful anyway. + * A *finished* season rolls nowhere and returns its last game instead, + months in the past — so dates have to be filtered to the future + before the soonest one means anything. + """ + import requests + + payload = requests.get(SCOREBOARD_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + + dates = [cls._parse_date(e.get('date')) + for e in payload.get('events') or []] + for entry in (payload.get('leagues') or [{}])[0].get('calendar') or []: + dates.append(cls._parse_date( + entry if isinstance(entry, str) else entry.get('startDate'))) + + # Count the last day as current, rather than filtering on "later than + # right now": a game that began a few hours ago still means the league + # has something on, and dropping it would report a live slate as a + # finished season. A day's grace also keeps this correct whatever the + # user's timezone, since these timestamps are UTC. + now = datetime.now(timezone.utc) + upcoming = sorted(d for d in dates if d and (now - d).days < 1) + if not upcoming: + if not any(dates): + return None # Nothing published either way; draw no conclusion. + return ("the season has finished and the next one's fixtures are " + "not published yet") + + # A day or two out is just an off day, and saying so would be noise. + if (upcoming[0] - now).days < cls.GAP_DAYS: + return None + return "the league has nothing on until {}".format( + upcoming[0].strftime('%d %B %Y')) + + @staticmethod + def _parse_date(raw) -> Optional[datetime]: + if not raw or not isinstance(raw, str): + return None + try: + return datetime.fromisoformat(raw.replace('Z', '+00:00')) + except ValueError: + return None + + @classmethod + def _suggest(cls, code: str, teams: Dict[str, str]) -> str: + """Nearest matching code for a typo, as a ready-to-log clause.""" + upper = (code or "").strip().upper() + if not upper or code in teams: + return "" + + # Right code, wrong case — matching is case-sensitive. Guard on the case + # actually differing, so a valid code never draws this message. + for abbr in teams: + if abbr.upper() == upper: + return " Codes are case-sensitive; use {!r} ({}).".format( + abbr, teams[abbr]) + + ranked = cls._rank(upper, (a for a, n in teams.items() + if cls._abbreviates(upper, n)), teams) + if not ranked: + # Nicknames are often a fragment of a word rather than its initials: + # 'BAMA' sits inside 'Alabama' but abbreviates nothing in it. Require + # three characters, since shorter fragments match far too much. + if len(upper) >= 3: + ranked = cls._rank( + upper, + (a for a, n in teams.items() + if any(upper in w for w in cls._words(n))), + teams) + + if len(ranked) == 1: + return " Closest match is {!r} ({}).".format( + ranked[0], teams[ranked[0]]) + if ranked: + return " Did you mean {}?".format(", ".join( + "{!r} ({})".format(a, teams[a]) for a in ranked[:3])) + + # Otherwise fall back to similarity, against names before codes: a name + # gives more characters to compare and so produces fewer ties. + names = {n.upper(): a for a, n in teams.items()} + hits = difflib.get_close_matches(upper, list(names), n=1, cutoff=0.6) + if hits: + abbr = names[hits[0]] + return " Closest match is {!r} ({}).".format(abbr, teams[abbr]) + + code_hits = difflib.get_close_matches(upper, list(teams), n=1, cutoff=0.6) + if code_hits: + return " Closest match is {!r} ({}).".format( + code_hits[0], teams[code_hits[0]]) + return "" + + @staticmethod + def _words(name: str): + return [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + @classmethod + def _rank(cls, code: str, candidates, teams: Dict[str, str]): + """ + Order candidate codes best-first. + + A code that picks up the *first* word of the name wins, because that is + how people shorten team names: 'SCAR' for South Carolina starts at + 'South', whereas for Rutgers Scarlet Knights it starts mid-name. Without + this the tie is broken alphabetically and the obvious answer can land + third in the list. + """ + def key(abbr): + words = cls._words(teams.get(abbr, '')) + first_word_hit = bool(words) and words[0].startswith(code[:1]) + return (not first_word_hit, len(abbr), abbr) + + return sorted(set(candidates), key=key) + + @staticmethod + def _abbreviates(code: str, name: str) -> bool: + """ + Whether ``code`` reads as an abbreviation of ``name``. + + Each part of the code must be a prefix of one of the name's words, taken + in order — which is how people actually shorten team names. Plain string + similarity is no use for three-letter codes: 'MUN' scores identically + against 'MAN' and 'SUN', so Manchester United and Sunderland tie and the + suggestion is a coin flip. This rule separates them, because 'MUN' + splits as M-anchester UN-ited while Sunderland has no word starting M. + """ + words = [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + def consume(rest, remaining): + if not rest: + return True + if not remaining: + return False + head, tail = remaining[0], remaining[1:] + # Skip this word entirely, as in "Manchester United" -> "UTD". + if consume(rest, tail): + return True + for size in range(1, min(len(rest), len(head)) + 1): + if head.startswith(rest[:size]) and consume(rest[size:], tail): + return True + return False + + return consume((code or "").strip().upper(), words) diff --git a/plugins/hockey-scoreboard/manager.py b/plugins/hockey-scoreboard/manager.py index f3094b9c..45353c8a 100644 --- a/plugins/hockey-scoreboard/manager.py +++ b/plugins/hockey-scoreboard/manager.py @@ -43,6 +43,14 @@ ) from hockey_timezone import resolve_timezone_name +from hockey_favorite_check import FavoriteTeamCheck + +# Which ESPN endpoint backs each league, for the favorite-team diagnostic. +FAVORITE_CHECK_LEAGUES = { + 'nhl': ('NHL', 'hockey/nhl'), + 'ncaa_mens': ("NCAA Men's Hockey", 'hockey/mens-college-hockey'), + 'ncaa_womens': ("NCAA Women's Hockey", 'hockey/womens-college-hockey'), +} logger = logging.getLogger(__name__) @@ -956,11 +964,39 @@ def _ensure_manager_updated(self, manager) -> None: except Exception as exc: self.logger.debug(f"Auto-refresh failed for manager {manager}: {exc}") + def _check_favorite_teams(self) -> None: + """ + Say why an enabled league is showing nothing. + + A favourite that is not a real ESPN abbreviation matches no game, and so + does a correct one before its season starts; both look like an empty + screen. The check runs in the background, once per league per process, + and never affects what is displayed. + """ + try: + checker = getattr(self, "_favorite_check", None) + if checker is None: + checker = FavoriteTeamCheck(self.logger, FAVORITE_CHECK_LEAGUES) + self._favorite_check = checker + for league in FAVORITE_CHECK_LEAGUES: + if not getattr(self, "{}_enabled".format(league), False): + continue + for mode in ("live", "recent", "upcoming"): + manager = getattr(self, "{}_{}".format(league, mode), None) + favorites = getattr(manager, "favorite_teams", None) + if favorites: + checker.schedule(league, favorites) + break + except Exception as exc: + self.logger.debug("Favorite team check skipped: %s", exc) + def update(self) -> None: """Update hockey game data.""" if not self.is_enabled: return + self._check_favorite_teams() + current_time = time.time() # Log plugin update calls for debugging (every 5 minutes) if not hasattr(self, '_last_plugin_update_log') or current_time - self._last_plugin_update_log >= 300: diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index f5e75402..ada9243a 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.5.3", + "version": "1.6.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,11 @@ } ], "versions": [ + { + "released": "2026-08-02", + "version": "1.6.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-08-02", "version": "1.5.3", diff --git a/plugins/hockey-scoreboard/test_favorite_check.py b/plugins/hockey-scoreboard/test_favorite_check.py new file mode 100644 index 00000000..02dfbcfe --- /dev/null +++ b/plugins/hockey-scoreboard/test_favorite_check.py @@ -0,0 +1,340 @@ +""" +Tests for the favorite-team diagnostic. + +Everything here is offline: ESPN is replaced with a fixed roster, so the tests +pin the *messages* the user actually sees. They are the point of the feature — +the whole thing exists to turn a blank screen into a sentence that says why. +""" + +import logging +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from hockey_favorite_check import FavoriteTeamCheck # noqa: E402 + +NHL = { + "BOS": "Boston Bruins", + "TB": "Tampa Bay Lightning", + "UTAH": "Utah Mammoth", + "SEA": "Seattle Kraken", + "VGK": "Vegas Golden Knights", +} + +NCAA = { + "ALA": "Alabama Crimson Tide", + "UNA": "North Alabama Lions", + "CONN": "UConn Huskies", + "SC": "South Carolina Gamecocks", + "RUTG": "Rutgers Scarlet Knights", + "GS": "Golden State Warriors", +} + + +class RecordingLogger(logging.Logger): + """Captures formatted records so tests can assert on the message text.""" + + def __init__(self): + super().__init__("test") + self.records = [] + + def handle(self, record): + self.records.append((record.levelname, record.getMessage())) + + def messages(self, level=None): + return [m for lvl, m in self.records if level is None or lvl == level] + + +class SuggestionTests(unittest.TestCase): + """The suggestion has to name the right team, and name it first.""" + + def assert_first_suggestion(self, code, teams, expected): + message = FavoriteTeamCheck._suggest(code, teams) + self.assertIn(expected, message, + "{!r} did not suggest {!r}: {}".format(code, expected, message)) + # Where several candidates are listed, the right one must lead, since + # users act on the first thing they read. + head = message.split("(")[0] + self.assertIn(expected, head, + "{!r} buried {!r} behind another suggestion: {}".format( + code, expected, message)) + + def test_retired_code_points_at_the_current_one(self): + self.assert_first_suggestion("UTA", NHL, "UTAH") + + def test_common_wrong_guesses(self): + self.assert_first_suggestion("GSW", NCAA, "GS") + self.assert_first_suggestion("UCONN", NCAA, "CONN") + + def test_fragment_of_a_word_is_matched(self): + # 'BAMA' abbreviates nothing in 'Alabama Crimson Tide' -- it is a chunk + # out of the middle of a word -- so the initials rule alone misses it. + self.assert_first_suggestion("BAMA", NCAA, "ALA") + + def test_first_word_beats_a_mid_name_match(self): + # 'SCAR' fits Rutgers *Scar*let too, but South Carolina starts at the + # first word, which is how people actually shorten a name. + self.assert_first_suggestion("SCAR", NCAA, "SC") + + def test_wrong_case_says_so_rather_than_guessing(self): + message = FavoriteTeamCheck._suggest("bos", NHL) + self.assertIn("case-sensitive", message) + self.assertIn("'BOS'", message) + + def test_valid_code_draws_no_suggestion(self): + self.assertEqual(FavoriteTeamCheck._suggest("BOS", NHL), "") + + def test_unmatchable_code_is_not_forced_into_a_suggestion(self): + # Nothing sensible to offer is better than something wrong. + self.assertEqual(FavoriteTeamCheck._suggest("ZZZZZZ", NHL), "") + + def test_abbreviates_separates_codes_string_distance_ties(self): + # The case that motivated this: 'MUN' is equidistant from 'MAN' and + # 'SUN' by string similarity, so similarity cannot choose. + self.assertTrue(FavoriteTeamCheck._abbreviates("MUN", "Manchester United")) + self.assertFalse(FavoriteTeamCheck._abbreviates("MUN", "Sunderland")) + + +class CheckTests(unittest.TestCase): + """The log output for each way a league can end up empty.""" + + def setUp(self): + self.logger = RecordingLogger() + self.checker = FavoriteTeamCheck(self.logger, {"nhl": ("NHL", "hockey/nhl")}) + self.checker._fetch_teams = staticmethod(lambda path: dict(NHL)) + self.checker._schedule_note = staticmethod(lambda path: None) + + def test_bad_code_is_reported_with_a_suggestion(self): + self.checker._check("nhl", ["UTA", "BOS"]) + warnings = self.logger.messages("WARNING") + self.assertEqual(len(warnings), 1) + self.assertIn("'UTA' is not a NHL team code", warnings[0]) + self.assertIn("UTAH", warnings[0]) + + def test_all_codes_bad_says_nothing_will_show(self): + self.checker._check("nhl", ["UTA", "NOPE"]) + joined = " ".join(self.logger.messages("WARNING")) + self.assertIn("no recognised favorite teams", joined) + self.assertIn("nothing will be shown", joined) + + def test_good_codes_out_of_season_explain_the_empty_screen(self): + self.checker._schedule_note = staticmethod( + lambda path: "the league has nothing on until 07 October 2026") + self.checker._check("nhl", ["BOS"]) + info = " ".join(self.logger.messages("INFO")) + self.assertIn("look correct", info) + self.assertIn("07 October 2026", info) + self.assertIn("not a configuration problem", info) + self.assertEqual(self.logger.messages("WARNING"), []) + + def test_good_codes_in_season_stay_quiet(self): + self.checker._check("nhl", ["BOS", "TB"]) + self.assertEqual(self.logger.messages("WARNING"), []) + self.assertIn("recognised: BOS, TB", " ".join(self.logger.messages("INFO"))) + + def test_dynamic_groups_are_not_treated_as_team_codes(self): + self.checker._check("nhl", ["AP_TOP_25", "NCAA_MENS_TOP_10"]) + self.assertEqual(self.logger.messages("WARNING"), []) + + def test_empty_roster_draws_no_conclusion(self): + # ESPN's college lacrosse endpoints return zero teams; a valid code + # must not be called wrong just because the roster is unavailable. + self.checker._fetch_teams = staticmethod(lambda path: {}) + self.checker._check("nhl", ["ANYTHING"]) + self.assertEqual(self.logger.records, []) + + def test_fetch_failure_is_swallowed(self): + def boom(path): + raise RuntimeError("network down") + + self.checker._fetch_teams = staticmethod(boom) + self.checker._check("nhl", ["BOS"]) # must not raise + self.assertEqual(self.logger.messages("WARNING"), []) + + +class ScheduleNoteTests(unittest.TestCase): + """ + Reading ESPN's scoreboard for "is there anything on?". + + Both traps here are real API behaviour, confirmed against live endpoints: + an out-of-season league rolls forward to its next fixtures rather than + returning nothing, and a finished season returns its *last* game instead. + """ + + def note(self, payload): + """Run _schedule_note against a fixed payload, with no network access. + + The method imports ``requests`` in its own body, so the fake has to go + into ``sys.modules`` — patching the attribute on the plugin module has + no effect on a function-local import. + """ + import types + + class Response: + @staticmethod + def json(): + return payload + + fake = types.ModuleType("requests") + fake.get = lambda url, timeout=None: Response() + + real = sys.modules.get("requests") + sys.modules["requests"] = fake + try: + return FavoriteTeamCheck._schedule_note("hockey/nhl") + finally: + if real is None: + sys.modules.pop("requests", None) + else: + sys.modules["requests"] = real + + @staticmethod + def iso(days): + from datetime import datetime, timedelta, timezone + return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat() + + def test_games_today_says_nothing(self): + self.assertIsNone(self.note({"events": [{"date": self.iso(0)}]})) + + def test_a_game_already_under_way_counts_as_something_on(self): + # Games that started earlier today are in the past by the clock. Reading + # them as "not upcoming" made a live slate report the season as over. + self.assertIsNone(self.note({"events": [{"date": self.iso(-0.3)}]})) + + def test_an_off_day_or_two_is_not_worth_mentioning(self): + self.assertIsNone(self.note({"events": [{"date": self.iso(1)}]})) + + def test_out_of_season_reports_the_next_fixture(self): + # ESPN rolls forward, so events exist but are months away. + note = self.note({"events": [{"date": self.iso(52)}, {"date": self.iso(53)}]}) + self.assertIsNotNone(note) + self.assertIn("nothing on until", note) + + def test_finished_season_is_reported_as_finished(self): + # A completed season returns its last game, in the past. + note = self.note({"events": [{"date": self.iso(-120)}], + "leagues": [{"calendar": [self.iso(-300)]}]}) + self.assertIsNotNone(note) + self.assertIn("season has finished", note) + + def test_past_dates_never_read_as_imminent(self): + # The bug this guards: taking the soonest of *all* dates makes a game + # from last March look like one happening right now, so a finished + # season silently reports itself as in progress. + note = self.note({"events": [{"date": self.iso(-120)}, + {"date": self.iso(40)}]}) + self.assertIn("nothing on until", note) + + def test_calendar_is_used_when_there_are_no_events(self): + note = self.note({"events": [], + "leagues": [{"calendar": [{"startDate": self.iso(30)}]}]}) + self.assertIn("nothing on until", note) + + def test_nothing_published_draws_no_conclusion(self): + self.assertIsNone(self.note({"events": [], "leagues": [{"calendar": []}]})) + + def test_unparseable_dates_are_ignored_rather_than_fatal(self): + self.assertIsNone(self.note( + {"events": [{"date": "not a date"}, {"date": None}, {}]})) + + +class SchedulingTests(unittest.TestCase): + """The check must run once, off the render path, and never raise.""" + + def setUp(self): + self.logger = RecordingLogger() + self.checker = FavoriteTeamCheck(self.logger, {"nhl": ("NHL", "hockey/nhl")}) + self.calls = [] + self.checker._check = lambda key, favs: self.calls.append((key, list(favs))) + + def drain(self): + import threading + for thread in threading.enumerate(): + if thread.name == "favorite-team-check": + thread.join(timeout=5) + + def test_runs_once_per_league(self): + for _ in range(5): + self.checker.schedule("nhl", ["BOS"]) + self.drain() + self.assertEqual(len(self.calls), 1) + + def test_reset_allows_a_recheck_after_a_config_edit(self): + self.checker.schedule("nhl", ["BOS"]) + self.drain() + self.checker.reset() + self.checker.schedule("nhl", ["TB"]) + self.drain() + self.assertEqual([favs for _, favs in self.calls], [["BOS"], ["TB"]]) + + def test_no_favorites_configured_does_nothing(self): + self.checker.schedule("nhl", []) + self.checker.schedule("nhl", None) + self.checker.schedule("nhl", ["", " "]) + self.drain() + self.assertEqual(self.calls, []) + + def test_unknown_league_key_is_ignored(self): + self.checker.schedule("not-a-league", ["BOS"]) + self.drain() + self.assertEqual(self.calls, []) + + def test_thread_is_a_daemon_so_it_cannot_hold_up_shutdown(self): + import threading + started = threading.Event() + seen = {} + + def record(key, favs): + seen["daemon"] = threading.current_thread().daemon + started.set() + + self.checker._check = record + self.checker.schedule("nhl", ["BOS"]) + self.assertTrue(started.wait(timeout=5)) + self.assertTrue(seen["daemon"]) + + +class CopyParityTests(unittest.TestCase): + """ + Every plugin ships its own copy of this module, because the loader gives + plugins no shared library to import from. Copies drift silently, so assert + they are identical while they all sit in the same checkout. Skipped once a + plugin is installed on its own, where the siblings do not exist. + """ + + SIBLINGS = { + "basketball-scoreboard": "basketball_favorite_check.py", + "football-scoreboard": "football_favorite_check.py", + "baseball-scoreboard": "baseball_favorite_check.py", + "lacrosse-scoreboard": "lacrosse_favorite_check.py", + "afl-scoreboard": "afl_favorite_check.py", + "nrl-scoreboard": "nrl_favorite_check.py", + } + + def test_all_copies_are_identical(self): + here = os.path.dirname(os.path.abspath(__file__)) + plugins_dir = os.path.dirname(here) + mine = os.path.join(here, "hockey_favorite_check.py") + with open(mine, "rb") as fh: + expected = fh.read() + + compared = 0 + for plugin, module in self.SIBLINGS.items(): + path = os.path.join(plugins_dir, plugin, module) + if not os.path.exists(path): + continue + with open(path, "rb") as fh: + self.assertEqual( + fh.read(), expected, + "{}/{} has drifted from hockey_favorite_check.py; the copies " + "must stay byte-identical".format(plugin, module)) + compared += 1 + + if not compared: + self.skipTest("sibling plugins not present in this checkout") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/plugins/lacrosse-scoreboard/CHANGELOG.md b/plugins/lacrosse-scoreboard/CHANGELOG.md index b7e1e465..a454e94b 100644 --- a/plugins/lacrosse-scoreboard/CHANGELOG.md +++ b/plugins/lacrosse-scoreboard/CHANGELOG.md @@ -1,3 +1,10 @@ +# Changelog + +## [1.6.0] - 2026-07-29 + +### Fixed +- Explain an empty screen instead of leaving the user guessing. A favorite team code that is not a real ESPN abbreviation matched no game and showed nothing, and so did a correct code before its season started - the two were indistinguishable from the logs. The plugin now says which it is, suggests the right code for a near miss (GBP -> GB), and reports when the league's next games are. The check runs in the background, once per league, and cannot affect what is displayed. + # Lacrosse Scoreboard — Changelog ## 1.5.1 (2026-07-30) diff --git a/plugins/lacrosse-scoreboard/lacrosse_favorite_check.py b/plugins/lacrosse-scoreboard/lacrosse_favorite_check.py new file mode 100644 index 00000000..37bbda4a --- /dev/null +++ b/plugins/lacrosse-scoreboard/lacrosse_favorite_check.py @@ -0,0 +1,308 @@ +""" +Explain an empty screen: a wrong team code, or a season that has not started. + +Favourite teams are matched by exact ESPN abbreviation, so a plausible-looking +code silently matches nothing and the plugin shows an empty screen with no hint +that the code is at fault. The codes are not always guessable — ESPN calls +Alabama ``ALA`` rather than ``BAMA``, and Golden State ``GS`` rather than +``GSW``. Between seasons a perfectly correct code produces the same empty +screen for a completely different reason, and the two were indistinguishable +from the logs. + +This module is diagnostics only. It runs on a daemon thread, once per league per +process, and every failure is swallowed: it must never delay a frame or change +what is displayed. +""" + +import difflib +import logging +import re +import threading +from datetime import datetime, timezone +from typing import Dict, Iterable, Optional, Tuple + +TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/teams?limit=1000" +SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/scoreboard" +REQUEST_TIMEOUT = 15 + + +class FavoriteTeamCheck: + """ + Validates configured favourite team codes against ESPN, and says so in the log. + + ``leagues`` maps the plugin's own league key to a + ``(human readable name, ESPN sport/league path)`` pair, e.g. + ``{'nhl': ('NHL', 'hockey/nhl')}``. + """ + + # How far out the next fixture has to be before it is worth mentioning. + # An off day or two is normal mid-season and saying so would just be noise. + GAP_DAYS = 3 + + def __init__(self, logger: Optional[logging.Logger], + leagues: Dict[str, Tuple[str, str]]) -> None: + self.logger = logger or logging.getLogger(__name__) + self.leagues = leagues + self._checked = set() + self._lock = threading.Lock() + + def reset(self) -> None: + """Re-check on the next call, e.g. after the user edits the config.""" + with self._lock: + self._checked.clear() + + def schedule(self, league_key: str, favorites: Iterable[str]) -> None: + """Check one league in the background, at most once per process.""" + try: + favorites = [str(f) for f in (favorites or []) if str(f).strip()] + if not favorites or league_key not in self.leagues: + return + with self._lock: + if league_key in self._checked: + return + self._checked.add(league_key) + threading.Thread( + target=self._run, args=(league_key, favorites), + name="favorite-team-check", daemon=True, + ).start() + except Exception: + pass # A diagnostic must never be the reason an update fails. + + def _run(self, league_key: str, favorites) -> None: + try: + self._check(league_key, favorites) + except Exception as exc: + self.logger.debug("Favorite team check failed for %s: %s", + league_key, exc) + + def _check(self, league_key: str, favorites) -> None: + name, path = self.leagues[league_key] + + try: + teams = self._fetch_teams(path) + except Exception as exc: + self.logger.debug("Could not verify %s favorite teams: %s", name, exc) + return + if not teams: + # Some ESPN endpoints (college lacrosse) return no teams at all. + # Nothing can be concluded, so say nothing. + return + + # Dynamic groups like AP_TOP_25 are expanded elsewhere; they are not + # team codes and must not be reported as bad ones. + codes = [f for f in favorites if not self._is_dynamic(f)] + recognised = [f for f in codes if f in teams] + unknown = [f for f in codes if f not in teams] + + for code in unknown: + self.logger.warning( + "%s favorite team %r is not a %s team code.%s " + "Every code this league accepts is listed at %s.", + name, code, name, self._suggest(code, teams), + TEAMS_URL.format(path=path), + ) + + if codes and not recognised: + self.logger.warning( + "%s has no recognised favorite teams, so nothing will be shown " + "for it. Codes must be ESPN abbreviations, e.g. %s.", + name, ", ".join("{} ({})".format(a, n) + for a, n in list(sorted(teams.items()))[:3]), + ) + return + + if not recognised: + return + + # Codes are fine, so check the other cause of an empty screen. + try: + note = self._schedule_note(path) + except Exception as exc: + self.logger.debug("Could not check the %s schedule: %s", name, exc) + return + + if note: + self.logger.info( + "%s favorite teams %s look correct, but %s. An empty display " + "until then is expected, not a configuration problem.", + name, ", ".join(recognised), note, + ) + else: + self.logger.info("%s favorite teams recognised: %s", + name, ", ".join(recognised)) + + @staticmethod + def _is_dynamic(code: str) -> bool: + upper = (code or "").strip().upper() + return upper.startswith("AP_") or upper.startswith("TOP_") or "TOP_" in upper + + @staticmethod + def _fetch_teams(path: str) -> Dict[str, str]: + """ESPN's {abbreviation: display name} for a league. + + ``limit=1000`` is required: the default page size truncates the NCAA + responses to roughly half their teams, which makes valid codes look wrong. + """ + import requests + + payload = requests.get(TEAMS_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + entries = payload['sports'][0]['leagues'][0]['teams'] + return { + t['team']['abbreviation']: t['team']['displayName'] + for t in entries if t.get('team', {}).get('abbreviation') + } + + @classmethod + def _schedule_note(cls, path: str) -> Optional[str]: + """ + Why the league has nothing to show, as a clause, or ``None`` if it does. + + Two things make this harder than reading ``events``: + + * An out-of-season league does not come back empty. ESPN rolls the + scoreboard forward to the next day that has fixtures, so in July the + NHL endpoint returns seven September games. Emptiness cannot be the + signal; the date of those games is, and it is more useful anyway. + * A *finished* season rolls nowhere and returns its last game instead, + months in the past — so dates have to be filtered to the future + before the soonest one means anything. + """ + import requests + + payload = requests.get(SCOREBOARD_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + + dates = [cls._parse_date(e.get('date')) + for e in payload.get('events') or []] + for entry in (payload.get('leagues') or [{}])[0].get('calendar') or []: + dates.append(cls._parse_date( + entry if isinstance(entry, str) else entry.get('startDate'))) + + # Count the last day as current, rather than filtering on "later than + # right now": a game that began a few hours ago still means the league + # has something on, and dropping it would report a live slate as a + # finished season. A day's grace also keeps this correct whatever the + # user's timezone, since these timestamps are UTC. + now = datetime.now(timezone.utc) + upcoming = sorted(d for d in dates if d and (now - d).days < 1) + if not upcoming: + if not any(dates): + return None # Nothing published either way; draw no conclusion. + return ("the season has finished and the next one's fixtures are " + "not published yet") + + # A day or two out is just an off day, and saying so would be noise. + if (upcoming[0] - now).days < cls.GAP_DAYS: + return None + return "the league has nothing on until {}".format( + upcoming[0].strftime('%d %B %Y')) + + @staticmethod + def _parse_date(raw) -> Optional[datetime]: + if not raw or not isinstance(raw, str): + return None + try: + return datetime.fromisoformat(raw.replace('Z', '+00:00')) + except ValueError: + return None + + @classmethod + def _suggest(cls, code: str, teams: Dict[str, str]) -> str: + """Nearest matching code for a typo, as a ready-to-log clause.""" + upper = (code or "").strip().upper() + if not upper or code in teams: + return "" + + # Right code, wrong case — matching is case-sensitive. Guard on the case + # actually differing, so a valid code never draws this message. + for abbr in teams: + if abbr.upper() == upper: + return " Codes are case-sensitive; use {!r} ({}).".format( + abbr, teams[abbr]) + + ranked = cls._rank(upper, (a for a, n in teams.items() + if cls._abbreviates(upper, n)), teams) + if not ranked: + # Nicknames are often a fragment of a word rather than its initials: + # 'BAMA' sits inside 'Alabama' but abbreviates nothing in it. Require + # three characters, since shorter fragments match far too much. + if len(upper) >= 3: + ranked = cls._rank( + upper, + (a for a, n in teams.items() + if any(upper in w for w in cls._words(n))), + teams) + + if len(ranked) == 1: + return " Closest match is {!r} ({}).".format( + ranked[0], teams[ranked[0]]) + if ranked: + return " Did you mean {}?".format(", ".join( + "{!r} ({})".format(a, teams[a]) for a in ranked[:3])) + + # Otherwise fall back to similarity, against names before codes: a name + # gives more characters to compare and so produces fewer ties. + names = {n.upper(): a for a, n in teams.items()} + hits = difflib.get_close_matches(upper, list(names), n=1, cutoff=0.6) + if hits: + abbr = names[hits[0]] + return " Closest match is {!r} ({}).".format(abbr, teams[abbr]) + + code_hits = difflib.get_close_matches(upper, list(teams), n=1, cutoff=0.6) + if code_hits: + return " Closest match is {!r} ({}).".format( + code_hits[0], teams[code_hits[0]]) + return "" + + @staticmethod + def _words(name: str): + return [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + @classmethod + def _rank(cls, code: str, candidates, teams: Dict[str, str]): + """ + Order candidate codes best-first. + + A code that picks up the *first* word of the name wins, because that is + how people shorten team names: 'SCAR' for South Carolina starts at + 'South', whereas for Rutgers Scarlet Knights it starts mid-name. Without + this the tie is broken alphabetically and the obvious answer can land + third in the list. + """ + def key(abbr): + words = cls._words(teams.get(abbr, '')) + first_word_hit = bool(words) and words[0].startswith(code[:1]) + return (not first_word_hit, len(abbr), abbr) + + return sorted(set(candidates), key=key) + + @staticmethod + def _abbreviates(code: str, name: str) -> bool: + """ + Whether ``code`` reads as an abbreviation of ``name``. + + Each part of the code must be a prefix of one of the name's words, taken + in order — which is how people actually shorten team names. Plain string + similarity is no use for three-letter codes: 'MUN' scores identically + against 'MAN' and 'SUN', so Manchester United and Sunderland tie and the + suggestion is a coin flip. This rule separates them, because 'MUN' + splits as M-anchester UN-ited while Sunderland has no word starting M. + """ + words = [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + def consume(rest, remaining): + if not rest: + return True + if not remaining: + return False + head, tail = remaining[0], remaining[1:] + # Skip this word entirely, as in "Manchester United" -> "UTD". + if consume(rest, tail): + return True + for size in range(1, min(len(rest), len(head)) + 1): + if head.startswith(rest[:size]) and consume(rest[size:], tail): + return True + return False + + return consume((code or "").strip().upper(), words) diff --git a/plugins/lacrosse-scoreboard/manager.py b/plugins/lacrosse-scoreboard/manager.py index 6f1b1253..3fa8f134 100644 --- a/plugins/lacrosse-scoreboard/manager.py +++ b/plugins/lacrosse-scoreboard/manager.py @@ -54,6 +54,13 @@ ) from lacrosse_timezone import resolve_timezone_name +from lacrosse_favorite_check import FavoriteTeamCheck + +# Which ESPN endpoint backs each league, for the favorite-team diagnostic. +FAVORITE_CHECK_LEAGUES = { + 'ncaa_mens': ("NCAA Men's Lacrosse", 'lacrosse/mens-college-lacrosse'), + 'ncaa_womens': ("NCAA Women's Lacrosse", 'lacrosse/womens-college-lacrosse'), +} logger = logging.getLogger(__name__) @@ -920,11 +927,39 @@ def _ensure_manager_updated(self, manager) -> None: except Exception as exc: self.logger.debug(f"Auto-refresh failed for manager {manager}: {exc}") + def _check_favorite_teams(self) -> None: + """ + Say why an enabled league is showing nothing. + + A favourite that is not a real ESPN abbreviation matches no game, and so + does a correct one before its season starts; both look like an empty + screen. The check runs in the background, once per league per process, + and never affects what is displayed. + """ + try: + checker = getattr(self, "_favorite_check", None) + if checker is None: + checker = FavoriteTeamCheck(self.logger, FAVORITE_CHECK_LEAGUES) + self._favorite_check = checker + for league in FAVORITE_CHECK_LEAGUES: + if not getattr(self, "{}_enabled".format(league), False): + continue + for mode in ("live", "recent", "upcoming"): + manager = getattr(self, "{}_{}".format(league, mode), None) + favorites = getattr(manager, "favorite_teams", None) + if favorites: + checker.schedule(league, favorites) + break + except Exception as exc: + self.logger.debug("Favorite team check skipped: %s", exc) + def update(self) -> None: """Update lacrosse game data.""" if not self.is_enabled: return + self._check_favorite_teams() + current_time = time.time() # Log plugin update calls for debugging (every 5 minutes) if not hasattr(self, '_last_plugin_update_log') or current_time - self._last_plugin_update_log >= 300: diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index 339e454c..064d04b6 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "lacrosse-scoreboard", "name": "Lacrosse Scoreboard", - "version": "1.5.2", + "version": "1.6.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,11 @@ } ], "versions": [ + { + "released": "2026-08-02", + "version": "1.6.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-08-02", "version": "1.5.2", diff --git a/plugins/nrl-scoreboard/manager.py b/plugins/nrl-scoreboard/manager.py index 27654f2d..51d7214d 100644 --- a/plugins/nrl-scoreboard/manager.py +++ b/plugins/nrl-scoreboard/manager.py @@ -54,6 +54,11 @@ from nrl_managers import create_nrl_managers, LEAGUE_NAMES, NRL_LEAGUE_SLUG from nrl_timezone import resolve_timezone_name +from nrl_favorite_check import FavoriteTeamCheck + +# Which ESPN endpoint backs the league, for the favorite-team diagnostic. +FAVORITE_CHECK_KEY = 'nrl' +FAVORITE_CHECK_LEAGUES = {FAVORITE_CHECK_KEY: ('NRL', 'rugby-league/3')} logger = logging.getLogger(__name__) @@ -460,11 +465,37 @@ def _ensure_manager_updated(self, manager) -> None: # ------------------------------------------------------------------ # Update # ------------------------------------------------------------------ + def _check_favorite_teams(self) -> None: + """ + Say why the league is showing nothing. + + A favourite that is not a real ESPN abbreviation matches no game, and so + does a correct one before its season starts; both look like an empty + screen. The check runs in the background, once per process, and never + affects what is displayed. + """ + try: + checker = getattr(self, "_favorite_check", None) + if checker is None: + checker = FavoriteTeamCheck(self.logger, FAVORITE_CHECK_LEAGUES) + self._favorite_check = checker + with self._config_lock: + managers = dict(self._managers) + for mode in ("live", "recent", "upcoming"): + favorites = getattr(managers.get(mode), "favorite_teams", None) + if favorites: + checker.schedule(FAVORITE_CHECK_KEY, favorites) + break + except Exception as exc: + self.logger.debug("Favorite team check skipped: %s", exc) + def update(self) -> None: """Update NRL game data using parallel manager updates.""" if not self.is_enabled: return + self._check_favorite_teams() + with self._config_lock: managers_snapshot = dict(self._managers) @@ -764,6 +795,11 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: self.logger.info(f"NRL config updated at runtime - reinitialized. Modes: {self.modes}") + # Favorites may have changed, so let the diagnostic report on them again. + checker = getattr(self, "_favorite_check", None) + if checker is not None: + checker.reset() + # ------------------------------------------------------------------ # Info # ------------------------------------------------------------------ diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 1a771b7f..bab0ee7d 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "nrl-scoreboard", "name": "NRL Scoreboard", - "version": "1.1.2", + "version": "1.2.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,11 @@ "nrl_upcoming" ], "versions": [ + { + "released": "2026-08-02", + "version": "1.2.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-08-02", "version": "1.1.2", diff --git a/plugins/nrl-scoreboard/nrl_favorite_check.py b/plugins/nrl-scoreboard/nrl_favorite_check.py new file mode 100644 index 00000000..37bbda4a --- /dev/null +++ b/plugins/nrl-scoreboard/nrl_favorite_check.py @@ -0,0 +1,308 @@ +""" +Explain an empty screen: a wrong team code, or a season that has not started. + +Favourite teams are matched by exact ESPN abbreviation, so a plausible-looking +code silently matches nothing and the plugin shows an empty screen with no hint +that the code is at fault. The codes are not always guessable — ESPN calls +Alabama ``ALA`` rather than ``BAMA``, and Golden State ``GS`` rather than +``GSW``. Between seasons a perfectly correct code produces the same empty +screen for a completely different reason, and the two were indistinguishable +from the logs. + +This module is diagnostics only. It runs on a daemon thread, once per league per +process, and every failure is swallowed: it must never delay a frame or change +what is displayed. +""" + +import difflib +import logging +import re +import threading +from datetime import datetime, timezone +from typing import Dict, Iterable, Optional, Tuple + +TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/teams?limit=1000" +SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/scoreboard" +REQUEST_TIMEOUT = 15 + + +class FavoriteTeamCheck: + """ + Validates configured favourite team codes against ESPN, and says so in the log. + + ``leagues`` maps the plugin's own league key to a + ``(human readable name, ESPN sport/league path)`` pair, e.g. + ``{'nhl': ('NHL', 'hockey/nhl')}``. + """ + + # How far out the next fixture has to be before it is worth mentioning. + # An off day or two is normal mid-season and saying so would just be noise. + GAP_DAYS = 3 + + def __init__(self, logger: Optional[logging.Logger], + leagues: Dict[str, Tuple[str, str]]) -> None: + self.logger = logger or logging.getLogger(__name__) + self.leagues = leagues + self._checked = set() + self._lock = threading.Lock() + + def reset(self) -> None: + """Re-check on the next call, e.g. after the user edits the config.""" + with self._lock: + self._checked.clear() + + def schedule(self, league_key: str, favorites: Iterable[str]) -> None: + """Check one league in the background, at most once per process.""" + try: + favorites = [str(f) for f in (favorites or []) if str(f).strip()] + if not favorites or league_key not in self.leagues: + return + with self._lock: + if league_key in self._checked: + return + self._checked.add(league_key) + threading.Thread( + target=self._run, args=(league_key, favorites), + name="favorite-team-check", daemon=True, + ).start() + except Exception: + pass # A diagnostic must never be the reason an update fails. + + def _run(self, league_key: str, favorites) -> None: + try: + self._check(league_key, favorites) + except Exception as exc: + self.logger.debug("Favorite team check failed for %s: %s", + league_key, exc) + + def _check(self, league_key: str, favorites) -> None: + name, path = self.leagues[league_key] + + try: + teams = self._fetch_teams(path) + except Exception as exc: + self.logger.debug("Could not verify %s favorite teams: %s", name, exc) + return + if not teams: + # Some ESPN endpoints (college lacrosse) return no teams at all. + # Nothing can be concluded, so say nothing. + return + + # Dynamic groups like AP_TOP_25 are expanded elsewhere; they are not + # team codes and must not be reported as bad ones. + codes = [f for f in favorites if not self._is_dynamic(f)] + recognised = [f for f in codes if f in teams] + unknown = [f for f in codes if f not in teams] + + for code in unknown: + self.logger.warning( + "%s favorite team %r is not a %s team code.%s " + "Every code this league accepts is listed at %s.", + name, code, name, self._suggest(code, teams), + TEAMS_URL.format(path=path), + ) + + if codes and not recognised: + self.logger.warning( + "%s has no recognised favorite teams, so nothing will be shown " + "for it. Codes must be ESPN abbreviations, e.g. %s.", + name, ", ".join("{} ({})".format(a, n) + for a, n in list(sorted(teams.items()))[:3]), + ) + return + + if not recognised: + return + + # Codes are fine, so check the other cause of an empty screen. + try: + note = self._schedule_note(path) + except Exception as exc: + self.logger.debug("Could not check the %s schedule: %s", name, exc) + return + + if note: + self.logger.info( + "%s favorite teams %s look correct, but %s. An empty display " + "until then is expected, not a configuration problem.", + name, ", ".join(recognised), note, + ) + else: + self.logger.info("%s favorite teams recognised: %s", + name, ", ".join(recognised)) + + @staticmethod + def _is_dynamic(code: str) -> bool: + upper = (code or "").strip().upper() + return upper.startswith("AP_") or upper.startswith("TOP_") or "TOP_" in upper + + @staticmethod + def _fetch_teams(path: str) -> Dict[str, str]: + """ESPN's {abbreviation: display name} for a league. + + ``limit=1000`` is required: the default page size truncates the NCAA + responses to roughly half their teams, which makes valid codes look wrong. + """ + import requests + + payload = requests.get(TEAMS_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + entries = payload['sports'][0]['leagues'][0]['teams'] + return { + t['team']['abbreviation']: t['team']['displayName'] + for t in entries if t.get('team', {}).get('abbreviation') + } + + @classmethod + def _schedule_note(cls, path: str) -> Optional[str]: + """ + Why the league has nothing to show, as a clause, or ``None`` if it does. + + Two things make this harder than reading ``events``: + + * An out-of-season league does not come back empty. ESPN rolls the + scoreboard forward to the next day that has fixtures, so in July the + NHL endpoint returns seven September games. Emptiness cannot be the + signal; the date of those games is, and it is more useful anyway. + * A *finished* season rolls nowhere and returns its last game instead, + months in the past — so dates have to be filtered to the future + before the soonest one means anything. + """ + import requests + + payload = requests.get(SCOREBOARD_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + + dates = [cls._parse_date(e.get('date')) + for e in payload.get('events') or []] + for entry in (payload.get('leagues') or [{}])[0].get('calendar') or []: + dates.append(cls._parse_date( + entry if isinstance(entry, str) else entry.get('startDate'))) + + # Count the last day as current, rather than filtering on "later than + # right now": a game that began a few hours ago still means the league + # has something on, and dropping it would report a live slate as a + # finished season. A day's grace also keeps this correct whatever the + # user's timezone, since these timestamps are UTC. + now = datetime.now(timezone.utc) + upcoming = sorted(d for d in dates if d and (now - d).days < 1) + if not upcoming: + if not any(dates): + return None # Nothing published either way; draw no conclusion. + return ("the season has finished and the next one's fixtures are " + "not published yet") + + # A day or two out is just an off day, and saying so would be noise. + if (upcoming[0] - now).days < cls.GAP_DAYS: + return None + return "the league has nothing on until {}".format( + upcoming[0].strftime('%d %B %Y')) + + @staticmethod + def _parse_date(raw) -> Optional[datetime]: + if not raw or not isinstance(raw, str): + return None + try: + return datetime.fromisoformat(raw.replace('Z', '+00:00')) + except ValueError: + return None + + @classmethod + def _suggest(cls, code: str, teams: Dict[str, str]) -> str: + """Nearest matching code for a typo, as a ready-to-log clause.""" + upper = (code or "").strip().upper() + if not upper or code in teams: + return "" + + # Right code, wrong case — matching is case-sensitive. Guard on the case + # actually differing, so a valid code never draws this message. + for abbr in teams: + if abbr.upper() == upper: + return " Codes are case-sensitive; use {!r} ({}).".format( + abbr, teams[abbr]) + + ranked = cls._rank(upper, (a for a, n in teams.items() + if cls._abbreviates(upper, n)), teams) + if not ranked: + # Nicknames are often a fragment of a word rather than its initials: + # 'BAMA' sits inside 'Alabama' but abbreviates nothing in it. Require + # three characters, since shorter fragments match far too much. + if len(upper) >= 3: + ranked = cls._rank( + upper, + (a for a, n in teams.items() + if any(upper in w for w in cls._words(n))), + teams) + + if len(ranked) == 1: + return " Closest match is {!r} ({}).".format( + ranked[0], teams[ranked[0]]) + if ranked: + return " Did you mean {}?".format(", ".join( + "{!r} ({})".format(a, teams[a]) for a in ranked[:3])) + + # Otherwise fall back to similarity, against names before codes: a name + # gives more characters to compare and so produces fewer ties. + names = {n.upper(): a for a, n in teams.items()} + hits = difflib.get_close_matches(upper, list(names), n=1, cutoff=0.6) + if hits: + abbr = names[hits[0]] + return " Closest match is {!r} ({}).".format(abbr, teams[abbr]) + + code_hits = difflib.get_close_matches(upper, list(teams), n=1, cutoff=0.6) + if code_hits: + return " Closest match is {!r} ({}).".format( + code_hits[0], teams[code_hits[0]]) + return "" + + @staticmethod + def _words(name: str): + return [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + @classmethod + def _rank(cls, code: str, candidates, teams: Dict[str, str]): + """ + Order candidate codes best-first. + + A code that picks up the *first* word of the name wins, because that is + how people shorten team names: 'SCAR' for South Carolina starts at + 'South', whereas for Rutgers Scarlet Knights it starts mid-name. Without + this the tie is broken alphabetically and the obvious answer can land + third in the list. + """ + def key(abbr): + words = cls._words(teams.get(abbr, '')) + first_word_hit = bool(words) and words[0].startswith(code[:1]) + return (not first_word_hit, len(abbr), abbr) + + return sorted(set(candidates), key=key) + + @staticmethod + def _abbreviates(code: str, name: str) -> bool: + """ + Whether ``code`` reads as an abbreviation of ``name``. + + Each part of the code must be a prefix of one of the name's words, taken + in order — which is how people actually shorten team names. Plain string + similarity is no use for three-letter codes: 'MUN' scores identically + against 'MAN' and 'SUN', so Manchester United and Sunderland tie and the + suggestion is a coin flip. This rule separates them, because 'MUN' + splits as M-anchester UN-ited while Sunderland has no word starting M. + """ + words = [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + def consume(rest, remaining): + if not rest: + return True + if not remaining: + return False + head, tail = remaining[0], remaining[1:] + # Skip this word entirely, as in "Manchester United" -> "UTD". + if consume(rest, tail): + return True + for size in range(1, min(len(rest), len(head)) + 1): + if head.startswith(rest[:size]) and consume(rest[size:], tail): + return True + return False + + return consume((code or "").strip().upper(), words)