From 42a82cc5cabe2ff88b6041ff8af864a36f277375 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 21:12:02 -0400 Subject: [PATCH 1/3] Fix wrong ESPN team codes in pickers and help text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several plugins documented — or in one case offered in a picker — team abbreviations that ESPN does not use, so copying them matched no team and the plugin silently showed nothing. odds-ticker's NHL picker was the only one where the user could not work around it: it listed UTA (a retired code, labelled with the club's former name "Utah Hockey Club" rather than "Utah Mammoth") and omitted the Seattle Kraken entirely, so that team could not be selected at all. The enum and labels are now generated from ESPN's team endpoint and match it exactly at 32 teams. The rest are description-only corrections to the favorite_teams examples: basketball NBA GSW -> GS (Golden State Warriors) basketball WNBA NYL -> NY (New York Liberty) basketball WNBA LAS -> LA (Los Angeles Sparks) basketball NCAAW UCONN -> CONN (UConn Huskies) basketball NCAAW SCAR -> SC (South Carolina Gamecocks) football NCAAFB BAMA -> ALA (Alabama Crimson Tide) hockey NCAAWH WISC -> WIS (Wisconsin Badgers) Each description now also says these are ESPN's codes and are not always the ones you would guess, since that is the underlying trap. Every code here was verified against site.api.espn.com/apis/site/v2/sports/{sport}/{league}/teams?limit=1000. The limit matters: without it the default page size truncates the NCAA responses (362 of 755 teams) and makes valid codes look wrong. Left alone deliberately: lacrosse-scoreboard's WISC/MINN/OSU and BU/BC/MICH examples, and baseball-scoreboard's MiLB DUR/SWB/NOR, because ESPN's lacrosse team endpoints return zero teams and the MiLB one 404s. Unverifiable, so not guessed at. No rendering code changed; the safety harness passes for all four plugins at every size. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- plugins.json | 8 ++++---- plugins/basketball-scoreboard/config_schema.json | 6 +++--- plugins/basketball-scoreboard/manifest.json | 8 +++++++- plugins/football-scoreboard/CHANGELOG.md | 5 +++++ plugins/football-scoreboard/config_schema.json | 2 +- plugins/football-scoreboard/manifest.json | 8 +++++++- plugins/hockey-scoreboard/config_schema.json | 2 +- plugins/hockey-scoreboard/manifest.json | 8 +++++++- plugins/odds-ticker/config_schema.json | 7 ++++--- plugins/odds-ticker/manifest.json | 8 +++++++- 10 files changed, 46 insertions(+), 16 deletions(-) diff --git a/plugins.json b/plugins.json index 9fe114a5..7cce799e 100644 --- a/plugins.json +++ b/plugins.json @@ -101,7 +101,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.8.0" + "latest_version": "1.8.1" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "2.9.0" + "latest_version": "2.9.1" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.5.0", + "latest_version": "1.5.1", "icon": "fas fa-hockey-puck" }, { @@ -608,7 +608,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.1.7" + "latest_version": "1.1.8" }, { "id": "of-the-day", diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index 3abc13a2..d437602e 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -85,7 +85,7 @@ "type": "string" }, "default": [], - "description": "List of favorite NBA team abbreviations (e.g., LAL, BOS, GSW). Use 2-3 letter codes." + "description": "List of favorite NBA team abbreviations (e.g., LAL, BOS, GS). Use 2-3 letter codes. These are ESPN's codes, which are not always the ones you would guess: Golden State is GS, not GSW." }, "exclude_teams": { "x-advanced": true, @@ -457,7 +457,7 @@ "type": "string" }, "default": [], - "description": "List of favorite WNBA team abbreviations (e.g., NYL, LAS, SEA). Use 2-3 letter codes." + "description": "List of favorite WNBA team abbreviations (e.g., NY, LA, SEA). Use 2-3 letter codes. These are ESPN's codes: New York Liberty is NY and Los Angeles Sparks is LA." }, "exclude_teams": { "x-advanced": true, @@ -1239,7 +1239,7 @@ "type": "string" }, "default": [], - "description": "List of favorite NCAA Women's Basketball team abbreviations (e.g., UCONN, SCAR, STAN). Use 2-4 letter codes." + "description": "List of favorite NCAA Women's Basketball team abbreviations (e.g., CONN, SC, STAN). Use 2-4 letter codes. These are ESPN's codes: UConn is CONN and South Carolina is SC." }, "exclude_teams": { "x-advanced": true, diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 4ecef0b6..85258373 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.0", + "version": "1.8.1", "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,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "released": "2026-07-29", + "version": "1.8.1", + "notes": "Correct the example team codes in the favorite-teams help text. They are ESPN's codes and are not always the ones you would guess: Golden State is GS not GSW, New York Liberty is NY not NYL, Los Angeles Sparks is LA not LAS, UConn is CONN not UCONN, and South Carolina is SC not SCAR. Copying the old examples matched no team and showed nothing.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-29", "version": "1.8.0", diff --git a/plugins/football-scoreboard/CHANGELOG.md b/plugins/football-scoreboard/CHANGELOG.md index 5bedc5b8..5b1fffe6 100644 --- a/plugins/football-scoreboard/CHANGELOG.md +++ b/plugins/football-scoreboard/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [2.9.1] - 2026-07-29 + +### Fixed +- Correct the NCAA football example team codes in the favorite-teams help text: Alabama is ALA in ESPN's data, not BAMA. Copying the old example matched no team and showed nothing. + ## [2.9.0] - 2026-07-29 ### Fixed diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index 93850c3d..2be7e631 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -416,7 +416,7 @@ "type": "string" }, "default": [], - "description": "List of favorite NCAA FB team abbreviations (e.g., UGA, AUB, BAMA). Use 2-4 letter codes." + "description": "List of favorite NCAA FB team abbreviations (e.g., UGA, AUB, ALA). Use 2-4 letter codes. These are ESPN's codes: Alabama is ALA, not BAMA." }, "exclude_teams": { "x-advanced": true, diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 53406dad..f73d449b 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.0", + "version": "2.9.1", "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,12 @@ "ncaa_fb_live" ], "versions": [ + { + "released": "2026-07-29", + "version": "2.9.1", + "notes": "Correct the NCAA football example team codes in the favorite-teams help text: Alabama is ALA in ESPN's data, not BAMA. Copying the old example matched no team and showed nothing.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-29", "version": "2.9.0", diff --git a/plugins/hockey-scoreboard/config_schema.json b/plugins/hockey-scoreboard/config_schema.json index 0a7ee5ec..d36ad807 100644 --- a/plugins/hockey-scoreboard/config_schema.json +++ b/plugins/hockey-scoreboard/config_schema.json @@ -996,7 +996,7 @@ "type": "array", "items": {"type": "string"}, "default": [], - "description": "NCAA Women's Hockey favorite team abbreviations (e.g., ['WISC', 'MINN', 'OSU'])" + "description": "NCAA Women's Hockey favorite team abbreviations (e.g., ['WIS', 'MINN', 'OSU']). These are ESPN's codes: Wisconsin is WIS, not WISC." }, "exclude_teams": { "x-advanced": true, diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 42b55aca..fa071b91 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.0", + "version": "1.5.1", "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,12 @@ } ], "versions": [ + { + "released": "2026-07-29", + "version": "1.5.1", + "notes": "Correct the NCAA women's hockey example team codes in the favorite-teams help text: Wisconsin is WIS in ESPN's data, not WISC. Copying the old example matched no team and showed nothing.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-29", "version": "1.5.0", diff --git a/plugins/odds-ticker/config_schema.json b/plugins/odds-ticker/config_schema.json index e6c878bb..0797f2ab 100644 --- a/plugins/odds-ticker/config_schema.json +++ b/plugins/odds-ticker/config_schema.json @@ -423,8 +423,8 @@ "enum": [ "ANA", "BOS", "BUF", "CAR", "CBJ", "CGY", "CHI", "COL", "DAL", "DET", "EDM", "FLA", "LA", "MIN", "MTL", "NJ", - "NSH", "NYI", "NYR", "OTT", "PHI", "PIT", "SJ", "STL", - "TB", "TOR", "UTA", "VAN", "VGK", "WSH", "WPG" + "NSH", "NYI", "NYR", "OTT", "PHI", "PIT", "SEA", "SJ", + "STL", "TB", "TOR", "UTAH", "VAN", "VGK", "WSH", "WPG" ] }, "uniqueItems": true, @@ -453,11 +453,12 @@ "OTT": "Ottawa Senators", "PHI": "Philadelphia Flyers", "PIT": "Pittsburgh Penguins", + "SEA": "Seattle Kraken", "SJ": "San Jose Sharks", "STL": "St. Louis Blues", "TB": "Tampa Bay Lightning", "TOR": "Toronto Maple Leafs", - "UTA": "Utah Hockey Club", + "UTAH": "Utah Mammoth", "VAN": "Vancouver Canucks", "VGK": "Vegas Golden Knights", "WSH": "Washington Capitals", diff --git a/plugins/odds-ticker/manifest.json b/plugins/odds-ticker/manifest.json index 15562667..158f8ac6 100644 --- a/plugins/odds-ticker/manifest.json +++ b/plugins/odds-ticker/manifest.json @@ -1,7 +1,7 @@ { "id": "odds-ticker", "name": "Odds Ticker", - "version": "1.1.7", + "version": "1.1.8", "description": "Displays scrolling odds and betting lines for upcoming games across multiple sports leagues including NFL, NBA, MLB, NCAA Football, and more", "author": "ChuckBuilds", "category": "sports", @@ -20,6 +20,12 @@ "branch": "main", "plugin_path": "plugins/odds-ticker", "versions": [ + { + "released": "2026-07-29", + "version": "1.1.8", + "notes": "Fix the NHL team picker, which listed UTA (retired code, labelled with the club's former name) instead of ESPN's UTAH, and omitted the Seattle Kraken entirely so they could not be selected at all. The list is now generated from ESPN and matches it exactly.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-18", "version": "1.1.7", From 88fc18248d7633c9780811372e05131f34c78ea1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 21:46:54 -0400 Subject: [PATCH 2/3] Explain an empty scoreboard instead of leaving the user guessing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Favorite teams are matched by exact ESPN abbreviation, so a code that is not real matches no game and the plugin shows nothing — with no hint that the code is the problem. Out of season, a perfectly correct code produces the identical empty screen. The two were indistinguishable from the logs, which is how a user ends up asking whether their config is broken when it is only July. Each of these plugins now says which case it is: WARNING NFL favorite team 'GBP' is not a NFL team code. Closest match is 'GB' (Green Bay Packers). Every code this league accepts is listed at https://site.api.espn.com/.../nfl/teams?limit=1000. INFO NFL favorite teams TB look correct, but the league has nothing on until 06 August 2026. An empty display until then is expected, not a configuration problem. INFO NCAA Baseball favorite teams UGA look correct, but the season has finished and the next one's fixtures are not published yet. Suggestions rank word-initial matches first, because string similarity is useless at three characters: 'MUN' scores identically against 'MAN' and 'SUN', so Manchester United and Sunderland tie and the answer is a coin flip. Fragments are handled too ('BAMA' is inside 'Alabama' but abbreviates nothing in it), and a code that only differs in case is told so rather than guessed at. Reading the schedule turned out to be the subtle part, and both traps are real ESPN behaviour confirmed against live endpoints: - An out-of-season league does not return an empty scoreboard. ESPN rolls forward to the next day with fixtures, so in July the NHL endpoint returns seven September games. Emptiness cannot be the signal. - A *finished* season rolls nowhere and returns its last game instead, months in the past — so dates must be filtered before the soonest one means anything. Filtering on "later than now" then wrongly drops games that started earlier today and reports a live slate as a dead season, so the window is the last 24 hours. Verified against every league these plugins cover: MLB, AFL, NRL and WNBA correctly stay quiet; NFL, NCAA football, NHL, NBA and NCAA men's basketball report their start dates; NCAA baseball and NCAA women's hockey report finished seasons. Safety, since this runs inside update(): - It runs on a daemon thread, so it never delays a frame. - Once per league per process, re-armed only when the config changes. - Every failure path is swallowed to a debug line. A plugin whose ESPN endpoint returns no teams at all (college lacrosse) draws no conclusion rather than calling a valid code wrong. Each plugin ships its own copy of the module, since the loader gives plugins no shared library to import from, under a plugin-unique name per the module-collision rule. A test asserts the copies stay byte-identical while they live in one checkout. Tested: 30 unit tests; safety harness 24/24 PASS per plugin (168 renders, zero failures); module-collision check clean. Validated end-to-end on real hardware, where all four message paths appeared as intended with no errors. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- plugins.json | 14 +- plugins/afl-scoreboard/afl_favorite_check.py | 308 ++++++++++++++++ plugins/afl-scoreboard/manager.py | 36 ++ plugins/afl-scoreboard/manifest.json | 8 +- plugins/baseball-scoreboard/CHANGELOG.md | 5 + .../baseball_favorite_check.py | 308 ++++++++++++++++ plugins/baseball-scoreboard/manager.py | 41 +++ plugins/baseball-scoreboard/manifest.json | 8 +- .../basketball_favorite_check.py | 308 ++++++++++++++++ plugins/basketball-scoreboard/manager.py | 37 ++ plugins/basketball-scoreboard/manifest.json | 8 +- plugins/football-scoreboard/CHANGELOG.md | 5 + .../football_favorite_check.py | 308 ++++++++++++++++ plugins/football-scoreboard/manager.py | 40 +++ plugins/football-scoreboard/manifest.json | 8 +- .../hockey_favorite_check.py | 308 ++++++++++++++++ plugins/hockey-scoreboard/manager.py | 36 ++ plugins/hockey-scoreboard/manifest.json | 8 +- .../hockey-scoreboard/test_favorite_check.py | 340 ++++++++++++++++++ plugins/lacrosse-scoreboard/CHANGELOG.md | 7 + .../lacrosse_favorite_check.py | 308 ++++++++++++++++ plugins/lacrosse-scoreboard/manager.py | 35 ++ plugins/lacrosse-scoreboard/manifest.json | 8 +- plugins/nrl-scoreboard/manager.py | 36 ++ plugins/nrl-scoreboard/manifest.json | 8 +- plugins/nrl-scoreboard/nrl_favorite_check.py | 308 ++++++++++++++++ 26 files changed, 2830 insertions(+), 14 deletions(-) create mode 100644 plugins/afl-scoreboard/afl_favorite_check.py create mode 100644 plugins/baseball-scoreboard/baseball_favorite_check.py create mode 100644 plugins/basketball-scoreboard/basketball_favorite_check.py create mode 100644 plugins/football-scoreboard/football_favorite_check.py create mode 100644 plugins/hockey-scoreboard/hockey_favorite_check.py create mode 100644 plugins/hockey-scoreboard/test_favorite_check.py create mode 100644 plugins/lacrosse-scoreboard/lacrosse_favorite_check.py create mode 100644 plugins/nrl-scoreboard/nrl_favorite_check.py diff --git a/plugins.json b/plugins.json index 7cce799e..1671bef9 100644 --- a/plugins.json +++ b/plugins.json @@ -76,7 +76,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.20.0" + "latest_version": "1.21.0" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.8.1" + "latest_version": "1.9.0" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "2.9.1" + "latest_version": "2.10.0" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.5.1", + "latest_version": "1.6.0", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.5.0", + "latest_version": "1.6.0", "icon": "fas fa-baseball-ball" }, { @@ -1023,7 +1023,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.1.0", + "latest_version": "1.2.0", "last_updated": "2026-07-17" }, { @@ -1070,7 +1070,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.1.0" + "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 c666ebae..20ad13dd 100644 --- a/plugins/afl-scoreboard/manager.py +++ b/plugins/afl-scoreboard/manager.py @@ -41,6 +41,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__) @@ -433,14 +438,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 eeb2554f..146eb325 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.0", + "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,12 @@ "afl_upcoming" ], "versions": [ + { + "released": "2026-07-29", + "version": "1.2.0", + "notes": "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.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-29", "version": "1.1.0", diff --git a/plugins/baseball-scoreboard/CHANGELOG.md b/plugins/baseball-scoreboard/CHANGELOG.md index 3c58c1c4..5ce35d62 100644 --- a/plugins/baseball-scoreboard/CHANGELOG.md +++ b/plugins/baseball-scoreboard/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.21.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. + ## [1.20.0] - 2026-07-28 ### 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 daa7fd2d..bd1e2852 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: @@ -327,6 +335,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 ( @@ -1064,11 +1077,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 8ff42bb8..3b469531 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.0", + "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,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-07-29", + "version": "1.21.0", + "notes": "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.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-28", "version": "1.20.0", 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 e83802de..b9e2107e 100644 --- a/plugins/basketball-scoreboard/manager.py +++ b/plugins/basketball-scoreboard/manager.py @@ -44,6 +44,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__) @@ -931,11 +940,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 85258373..dbbdc391 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.1", + "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,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "released": "2026-07-29", + "version": "1.9.0", + "notes": "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.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-29", "version": "1.8.1", diff --git a/plugins/football-scoreboard/CHANGELOG.md b/plugins/football-scoreboard/CHANGELOG.md index 5b1fffe6..df5cc5dc 100644 --- a/plugins/football-scoreboard/CHANGELOG.md +++ b/plugins/football-scoreboard/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [2.10.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. + ## [2.9.1] - 2026-07-29 ### 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 4f56c52f..391fac57 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__) @@ -307,6 +314,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 ( @@ -964,11 +976,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 f73d449b..0b5e2dfb 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.1", + "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,12 @@ "ncaa_fb_live" ], "versions": [ + { + "released": "2026-07-29", + "version": "2.10.0", + "notes": "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.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-29", "version": "2.9.1", 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 e661b224..a6127695 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__) @@ -974,11 +982,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 fa071b91..4fdb213c 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.1", + "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,12 @@ } ], "versions": [ + { + "released": "2026-07-29", + "version": "1.6.0", + "notes": "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.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-29", "version": "1.5.1", 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 04ead393..e9a6a921 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.0 (2026-07-29) 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 2c714e0d..a9ea4797 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__) @@ -919,11 +926,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 d1c4fac4..9f97ad3e 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.0", + "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,12 @@ } ], "versions": [ + { + "released": "2026-07-29", + "version": "1.6.0", + "notes": "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.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-29", "version": "1.5.0", diff --git a/plugins/nrl-scoreboard/manager.py b/plugins/nrl-scoreboard/manager.py index 3fe37cdf..5c96fcae 100644 --- a/plugins/nrl-scoreboard/manager.py +++ b/plugins/nrl-scoreboard/manager.py @@ -42,6 +42,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__) @@ -447,11 +452,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) @@ -751,6 +782,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 9a5ac087..146239ef 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.0", + "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,12 @@ "nrl_upcoming" ], "versions": [ + { + "released": "2026-07-29", + "version": "1.2.0", + "notes": "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.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-29", "version": "1.1.0", 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) From 34128517b42ff4177c3dbb43de14fcabf60efd62 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 13:36:19 -0400 Subject: [PATCH 3/3] chore: version the favorite-team diagnostics above current main Minor bumps (new user-facing feature) for the seven plugins #235 actually changes, each above main's post-#237 version: afl 1.2.0, baseball 1.21.0, basketball 1.9.0, football 2.10.0, hockey 1.6.0, lacrosse 1.6.0, nrl 1.2.0 odds-ticker is intentionally NOT bumped: its only change in #235 was the NHL picker correction (UTA->UTAH, +Seattle) that came from #234 and is already in main, so it has no net change here. The original bump list also targeted numbers main has since passed via #234/#236/#237/#239; corrected. plugins.json regenerated; every changed plugin is strictly above main. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- plugins.json | 14 +++++++------- plugins/afl-scoreboard/manifest.json | 7 ++++++- plugins/baseball-scoreboard/manifest.json | 7 ++++++- plugins/basketball-scoreboard/manifest.json | 7 ++++++- plugins/football-scoreboard/manifest.json | 7 ++++++- plugins/hockey-scoreboard/manifest.json | 7 ++++++- plugins/lacrosse-scoreboard/manifest.json | 7 ++++++- plugins/nrl-scoreboard/manifest.json | 7 ++++++- 8 files changed, 49 insertions(+), 14 deletions(-) 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/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/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/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/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/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/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/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",