From 7a26ac3ca499790e4fb7b67e56f456a8e07c44ec Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:26:17 -0400 Subject: [PATCH] fix(sports): offer the idle back-off only the kickoffs each board would show Football 3.18.7 (#622) stopped a favourites-only board from waking for other teams' kickoffs: the live loop offered every game on the scoreboard to core's idle back-off, and each kickoff holds the poll at the live cadence for 15 minutes, so the board fetched the whole scoreboard about every 30 s on a busy slate for games it then filtered out. The same rule now applies in the other eight scoreboards that offer kickoffs: afl, baseball, basketball, hockey, lacrosse, nrl, soccer and ufc. Each uses its own live filter (_classify_live_game; hockey's _is_live_game_included; basketball keeps tournament games in tournament mode; nrl matches team IDs). Baseball, lacrosse and ufc filtered inline in update(), so that chain moves into a _classify_live_game() both sites call. Boards showing every live game are unchanged. odds-ticker offers no kickoffs and is untouched. Replays under frozen time (fake gzipped ESPN): baseball, Fri 2026-09-25, favourite LAD: 927 -> 620 scoreboard requests, 19.6 -> 13.1 MB wire; hockey, Sat 2026-10-03, favourite LA: 645 -> 513, 12.7 -> 10.1 MB. Show-all boards make the same requests and show the same games. Versions (next patch above main): afl 1.37.1, baseball 1.59.1, basketball 1.44.1, hockey 1.44.1, lacrosse 1.37.1, nrl 1.36.1, soccer 2.41.1, ufc 1.20.1. Co-Authored-By: Claude Opus 5.5 --- plugins.json | 40 ++-- plugins/afl-scoreboard/CHANGELOG.md | 13 ++ plugins/afl-scoreboard/manifest.json | 8 +- plugins/afl-scoreboard/sports.py | 11 +- ...t_kickoff_offer_follows_the_live_filter.py | 201 ++++++++++++++++ plugins/baseball-scoreboard/CHANGELOG.md | 20 ++ plugins/baseball-scoreboard/manifest.json | 8 +- plugins/baseball-scoreboard/sports.py | 67 +++--- ...t_kickoff_offer_follows_the_live_filter.py | 201 ++++++++++++++++ plugins/basketball-scoreboard/CHANGELOG.md | 14 ++ plugins/basketball-scoreboard/manifest.json | 8 +- plugins/basketball-scoreboard/sports.py | 12 +- ...t_kickoff_offer_follows_the_live_filter.py | 214 ++++++++++++++++++ plugins/hockey-scoreboard/CHANGELOG.md | 16 ++ plugins/hockey-scoreboard/manifest.json | 8 +- plugins/hockey-scoreboard/sports.py | 11 +- ...t_kickoff_offer_follows_the_live_filter.py | 201 ++++++++++++++++ plugins/lacrosse-scoreboard/CHANGELOG.md | 15 ++ plugins/lacrosse-scoreboard/manifest.json | 8 +- plugins/lacrosse-scoreboard/sports.py | 63 +++--- ...t_kickoff_offer_follows_the_live_filter.py | 201 ++++++++++++++++ plugins/nrl-scoreboard/CHANGELOG.md | 13 ++ plugins/nrl-scoreboard/manifest.json | 8 +- plugins/nrl-scoreboard/sports.py | 11 +- ...t_kickoff_offer_follows_the_live_filter.py | 201 ++++++++++++++++ plugins/soccer-scoreboard/CHANGELOG.md | 13 ++ plugins/soccer-scoreboard/manifest.json | 8 +- plugins/soccer-scoreboard/sports.py | 11 +- ...t_kickoff_offer_follows_the_live_filter.py | 201 ++++++++++++++++ plugins/ufc-scoreboard/CHANGELOG.md | 14 ++ plugins/ufc-scoreboard/manifest.json | 8 +- plugins/ufc-scoreboard/sports.py | 58 +++-- ...t_kickoff_offer_follows_the_live_filter.py | 199 ++++++++++++++++ 33 files changed, 1963 insertions(+), 122 deletions(-) create mode 100644 plugins/afl-scoreboard/test_kickoff_offer_follows_the_live_filter.py create mode 100644 plugins/baseball-scoreboard/test_kickoff_offer_follows_the_live_filter.py create mode 100644 plugins/basketball-scoreboard/test_kickoff_offer_follows_the_live_filter.py create mode 100644 plugins/hockey-scoreboard/test_kickoff_offer_follows_the_live_filter.py create mode 100644 plugins/lacrosse-scoreboard/test_kickoff_offer_follows_the_live_filter.py create mode 100644 plugins/nrl-scoreboard/test_kickoff_offer_follows_the_live_filter.py create mode 100644 plugins/soccer-scoreboard/test_kickoff_offer_follows_the_live_filter.py create mode 100644 plugins/ufc-scoreboard/test_kickoff_offer_follows_the_live_filter.py diff --git a/plugins.json b/plugins.json index 4d3c274d..2c78a951 100644 --- a/plugins.json +++ b/plugins.json @@ -80,9 +80,8 @@ "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.59.0", - "ledmatrix_min_version": "3.8.1", - "commit": "751e973768d3205b420e9bed9c03df8601678601" + "latest_version": "1.59.1", + "ledmatrix_min_version": "3.8.1" }, { "id": "basketball-scoreboard", @@ -107,9 +106,8 @@ "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.44.0", - "ledmatrix_min_version": "3.8.1", - "commit": "751e973768d3205b420e9bed9c03df8601678601" + "latest_version": "1.44.1", + "ledmatrix_min_version": "3.8.1" }, { "id": "calendar", @@ -385,10 +383,9 @@ "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.44.0", + "latest_version": "1.44.1", "icon": "fas fa-hockey-puck", - "ledmatrix_min_version": "3.8.1", - "commit": "751e973768d3205b420e9bed9c03df8601678601" + "ledmatrix_min_version": "3.8.1" }, { "id": "lacrosse-scoreboard", @@ -411,10 +408,9 @@ "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.37.0", + "latest_version": "1.37.1", "icon": "fas fa-baseball-ball", - "ledmatrix_min_version": "3.8.1", - "commit": "540fd24022abf04f66b0657d923cac8ee0453aad" + "ledmatrix_min_version": "3.8.1" }, { "id": "birdnet-go", @@ -847,9 +843,8 @@ "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "2.41.0", - "ledmatrix_min_version": "3.8.1", - "commit": "751e973768d3205b420e9bed9c03df8601678601" + "latest_version": "2.41.1", + "ledmatrix_min_version": "3.8.1" }, { "id": "static-image", @@ -1007,10 +1002,9 @@ "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.20.0", + "latest_version": "1.20.1", "icon": "fas fa-fist-raised", - "ledmatrix_min_version": "3.8.1", - "commit": "540fd24022abf04f66b0657d923cac8ee0453aad" + "ledmatrix_min_version": "3.8.1" }, { "id": "weather", @@ -1164,10 +1158,9 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.37.0", + "latest_version": "1.37.1", "last_updated": "2026-10-05", - "ledmatrix_min_version": "3.8.1", - "commit": "751e973768d3205b420e9bed9c03df8601678601" + "ledmatrix_min_version": "3.8.1" }, { "id": "tidbyt-baseball-scoreboard", @@ -1213,9 +1206,8 @@ "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.36.0", - "ledmatrix_min_version": "3.8.1", - "commit": "751e973768d3205b420e9bed9c03df8601678601" + "latest_version": "1.36.1", + "ledmatrix_min_version": "3.8.1" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/CHANGELOG.md b/plugins/afl-scoreboard/CHANGELOG.md index 4c6368cc..2b24b225 100644 --- a/plugins/afl-scoreboard/CHANGELOG.md +++ b/plugins/afl-scoreboard/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.37.1] - 2026-10-05 + +### Fixed +- A board showing only its favourites' live games no longer wakes for other + teams' games. The live loop handed every game on the scoreboard to core's + idle back-off as a start to wake for, and each one holds the poll at the + live update interval for 15 minutes, so on a busy slate the board fetched + the whole scoreboard about every 30 s for games it then filtered out. Only + games the live filter would show are offered now (football-scoreboard 3.18.7 + made the same change). Excluded teams' games are never offered. Boards + showing every live game are unchanged. + `test_kickoff_offer_follows_the_live_filter.py` covers the filter. + ## [1.37.0] - 2026-10-05 ### Changed diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index a59199af..470b6bb0 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.37.0", + "version": "1.37.1", "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": [ + { + "version": "1.37.1", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.1", + "notes": "A favourites-only board no longer wakes for other teams' games. Every game the live fetch saw held the live poll at 30 s for 15 minutes, so on a busy slate it fetched the whole scoreboard about every 30 s for games it then filtered out. Only games the live filter would show are offered now, and excluded teams are honoured the same way. Boards that show every live game are unchanged." + }, { "version": "1.37.0", "released": "2026-10-05", diff --git a/plugins/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py index df7e9587..bd801e75 100644 --- a/plugins/afl-scoreboard/sports.py +++ b/plugins/afl-scoreboard/sports.py @@ -3626,9 +3626,18 @@ def update(self): # starts, so it cannot sleep through a kickoff. # getattr-guarded: the core version floor is # advisory, so an older core must stay loadable. + # + # Only a game this board would show once it is live. + # Every kickoff holds the poll at the live cadence for a + # quarter of an hour, so offering the games the live + # filter then drops kept a favourites-only board polling + # the whole scoreboard all day (football 3.18.7). _note_start = getattr( self, "_note_scheduled_start_candidate", None) - if _note_start is not None: + if (_note_start is not None and details + and self._classify_live_game( + details.get("home_abbr"), + details.get("away_abbr"))): _note_start(details) if details: # Log game status for debugging diff --git a/plugins/afl-scoreboard/test_kickoff_offer_follows_the_live_filter.py b/plugins/afl-scoreboard/test_kickoff_offer_follows_the_live_filter.py new file mode 100644 index 00000000..8c9c351a --- /dev/null +++ b/plugins/afl-scoreboard/test_kickoff_offer_follows_the_live_filter.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Tests that the live loop offers the idle back-off only the kickoffs it would show. + +The core mixin clamps the idle back-off to the next known kickoff and then +holds the live cadence for a quarter of an hour after it +(_clamp_to_scheduled_start / _note_scheduled_start_candidate, tested in the +core's test_sports_shared.py). It learns the kickoffs from this loop, which +offers the games the live fetch already downloaded. What is pinned here is +which games it offers: + + * every game the board would show once it is live -- a favourites-only + board offers only its favourites' games, and an excluded team's game is + never offered. Each kickoff holds the poll at live cadence for 15 + minutes, so offering games the board then filters out kept a + favourites-only board polling the whole scoreboard all day on a busy + slate (football-scoreboard 3.18.7 found it; this is the same rule); + * a board that shows every live game is unchanged: it offers every game, + upcoming ones included, even when nothing is live; + * the call is getattr-guarded, so a core without the method still loads. + +Built on the same real-SportsLive scaffolding as football-scoreboard's +test_kickoff_wakes_the_idle_poll.py. + +Run: /bin/python plugins/afl-scoreboard/test_kickoff_offer_follows_the_live_filter.py +""" + +# A test harness: it reaches into protected members on purpose, builds a +# concrete subclass at runtime, and accepts arguments only to match the +# signatures it stands in for -- none of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +# pylint: disable=broad-exception-caught + +import os +import sys +import threading +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +_core = os.environ.get("LEDMATRIX_CORE") +if _core and (Path(_core) / "src").is_dir(): + sys.path.insert(0, _core) + +try: + import sports # noqa: E402 +except ImportError as exc: # no core checkout on the path + print("SKIP: cannot import sports.py without a LEDMatrix core (%s)" % exc) + sys.exit(2) + +SPORT_KEY = "afl" +#: The keys a game's two sides are matched on by this plugin's live filter. +HOME, AWAY = "home_abbr", "away_abbr" +FAV, FAV_OPP, OTHER_H, OTHER_A = ("GEEL", "COLL", "CARL", "ESS") +#: The abbreviation each side shows, where it is not the key itself. +ABBR = {} + + +class _Logger: + def info(self, *a, **k): pass + def debug(self, *a, **k): pass + def warning(self, *a, **k): pass + def error(self, *a, **k): pass + def exception(self, *a, **k): pass + + +class _StubCore: + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = {} + + +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(): + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete({}, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +def _prime(live): + """The SportsCore/SportsLive state update() reads, which _StubCore skips.""" + live.is_enabled = True + live.live_games = [] + live.last_update = 0 + live.show_ranking = False + live.test_mode = False + live.no_data_interval = 300 + live.update_interval = 30 + live.live_idle_max_interval = 900 + live._empty_live_streak = 0 + live.sport_key = SPORT_KEY + live.league = SPORT_KEY + live.sport = SPORT_KEY + live._games_lock = threading.RLock() + live.favorite_teams = [] + live.exclude_teams = [] + live.show_all_live = True + live.show_favorite_teams_only = False + live.tournament_mode = False + live.show_odds = False + live._rotation_schedule = [] + live.current_game_index = 0 + live.current_game = None + live.last_game_switch = 0 + live.game_update_timestamps = {} + live.last_log_time = 0 + live.log_interval = 300 + live.stale_game_timeout = 600 + live._check_for_score = lambda *a, **k: None + return live + + +def _drive(events, **settings): + """Run one update() pass over a canned payload; return the offered ids.""" + live = _prime(_live()) + for name, value in settings.items(): + setattr(live, name, value) + offered = [] + live._fetch_data = lambda *a, **k: {"events": list(events)} + live._extract_game_details = lambda game: dict(game) + live._note_scheduled_start_candidate = lambda details: offered.append(details) + try: + live.update() + except Exception as exc: # noqa: BLE001 + print(" (update raised: %r)" % (exc,)) + return sorted(d.get("id") for d in offered if isinstance(d, dict)), live + + +def _game(gid, home, away, **extra): + game = {"id": gid, HOME: home, AWAY: away, "home_abbr": ABBR.get(home, home), + "away_abbr": ABBR.get(away, away), "is_live": False, "is_halftime": False, + "is_final": False} + game.update(extra) + return game + + +def main(): + games = [_game("fav", FAV, FAV_OPP), _game("other", OTHER_H, OTHER_A)] + + print("a board showing every live game offers every game") + ids, live = _drive(games) + check("show_all_live offers every game, upcoming ones included", + ids == ["fav", "other"]) + check("and no game was treated as live", live.live_games == []) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=True, + show_favorite_teams_only=True) + check("show_all_live still offers every game with favourites set", + ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=False) + check("favourites-only off offers every game", ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[], show_all_live=False, + show_favorite_teams_only=True) + check("favourites-only with no favourites offers every game", + ids == ["fav", "other"]) + + print("\na favourites-only board offers only its favourites' games") + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=True) + check("only the favourite's game was offered (%s)" % ids, ids == ["fav"]) + ids, _ = _drive(games, exclude_teams=[OTHER_A]) + check("an excluded team's game is never offered (%s)" % ids, ids == ["fav"]) + print("\nthe call is getattr-guarded") + live2 = _prime(_live()) + live2._note_scheduled_start_candidate = None + live2._fetch_data = lambda *a, **k: {"events": [games[0]]} + live2._extract_game_details = lambda game: dict(game) + raised = None + try: + live2.update() + except Exception as exc: # noqa: BLE001 + raised = exc + check("a core without the method does not raise (%r)" % (raised,), raised is None) + + if failures: + print("\n%d check(s) failed" % len(failures)) + return 1 + print("\nall checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/baseball-scoreboard/CHANGELOG.md b/plugins/baseball-scoreboard/CHANGELOG.md index 16d15f12..7c4eed10 100644 --- a/plugins/baseball-scoreboard/CHANGELOG.md +++ b/plugins/baseball-scoreboard/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.59.1] - 2026-10-05 + +### Fixed +- A board showing only its favourites' live games no longer wakes for other + teams' games. The live loop handed every game on the scoreboard to core's + idle back-off as a start to wake for, and each one holds the poll at the + live update interval for 15 minutes, so on a busy slate the board fetched + the whole scoreboard about every 30 s for games it then filtered out. Only + games the live filter would show are offered now (football-scoreboard 3.18.7 + made the same change). Excluded teams' games are never offered. Boards + showing every live game are unchanged. The live filter moved out of + `update()` into `_classify_live_game()`, as in the other scoreboards, so the + live list and the kickoff offer share one rule. Replaying Friday 2026-09-25 + under frozen time (09:00-03:00 ET, MLB): favourite LAD (one 22:15 game) 927 + -> 620 scoreboard requests, 19.6 -> 13.1 MB on the wire (273 -> 183 MB + decoded); favourites NYY and LAD 1,361 -> 1,293. Favourites' games showed + from the first look after they went live either way. Show-all: the same + 1,683 requests and the same games shown. + `test_kickoff_offer_follows_the_live_filter.py` covers the filter. + ## [1.59.0] - 2026-10-05 ### Changed diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index ad393f16..ea1eaea1 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.59.0", + "version": "1.59.1", "update_interval": 60, "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", @@ -31,6 +31,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.59.1", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.1", + "notes": "A favourites-only board no longer wakes for other teams' games. Every game the live fetch saw held the live poll at 30 s for 15 minutes, so on a busy slate it fetched the whole scoreboard about every 30 s for games it then filtered out. Only games the live filter would show are offered now, and excluded teams are honoured the same way. Replaying Friday 2026-09-25 (09:00-03:00 ET, favourite LAD): 927 -> 620 scoreboard requests, 19.6 -> 13.1 MB on the wire (273 -> 183 MB decoded). Boards that show every live game are unchanged." + }, { "version": "1.59.0", "released": "2026-10-05", diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index b0bddede..6f38510f 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -3061,6 +3061,23 @@ def _is_favorite_game(self, game: Dict) -> bool: or game.get("away_abbr") in self.favorite_teams ) + def _classify_live_game(self, home_abbr, away_abbr) -> bool: + """Whether a live game should be included in the live rotation. + + Priority: excluded team (never shown, overrides everything) > + show_all_live > show_favorite_teams_only disabled > no favorites + configured (fallback: show all) > favorite-teams-only membership. + """ + if home_abbr in self.exclude_teams or away_abbr in self.exclude_teams: + return False + if self.show_all_live: + return True + if not self.show_favorite_teams_only: + return True + if not self.favorite_teams: + return True + return home_abbr in self.favorite_teams or away_abbr in self.favorite_teams + def _keep_final_for_vegas(self, details: Dict) -> None: """Hold a game that just went final, so its live Vegas card shows FINAL. @@ -3182,9 +3199,18 @@ def update(self): # starts, so it cannot sleep through a kickoff. # getattr-guarded: the core version floor is # advisory, so an older core must stay loadable. + # + # Only a game this board would show once it is live. + # Every kickoff holds the poll at the live cadence for a + # quarter of an hour, so offering the games the live + # filter then drops kept a favourites-only board polling + # the whole scoreboard all day (football 3.18.7). _note_start = getattr( self, "_note_scheduled_start_candidate", None) - if _note_start is not None: + if (_note_start is not None and details + and self._classify_live_game( + details.get("home_abbr"), + details.get("away_abbr"))): _note_start(details) if details: # Log game status for debugging - use INFO level to see what's happening @@ -3273,35 +3299,16 @@ def update(self): home_abbr = details.get("home_abbr") away_abbr = details.get("away_abbr") - if home_abbr in self.exclude_teams or away_abbr in self.exclude_teams: - # Excluded teams are always hidden, regardless of every - # other filtering setting (spoiler protection) - should_include = False - include_reason = "excluded team" - elif self.show_all_live: - # Always show all live games if show_all_live is enabled - should_include = True - include_reason = "show_all_live=True" - elif not self.show_favorite_teams_only: - # If favorite teams filtering is disabled, show all games - should_include = True - include_reason = "show_favorite_teams_only=False" - elif not self.favorite_teams: - # If favorite teams filtering is enabled but no favorites are configured, - # show all games (same behavior as SportsUpcoming) - should_include = True - include_reason = "favorite_teams is empty" - else: - # Favorite teams filtering is enabled AND favorites are configured - # Only show games involving favorite teams - home_match = home_abbr in self.favorite_teams - away_match = away_abbr in self.favorite_teams - should_include = home_match or away_match - include_reason = ( - f"favorite_teams={self.favorite_teams}, " - f"home_abbr='{home_abbr}' in_favorites={home_match}, " - f"away_abbr='{away_abbr}' in_favorites={away_match}" - ) + # See _classify_live_game for the precedence order + # (exclude > show_all_live > favourites-only); the + # kickoff offer above applies the same rule. + should_include = self._classify_live_game(home_abbr, away_abbr) + include_reason = ( + f"exclude_teams={self.exclude_teams}, " + f"show_all_live={self.show_all_live}, " + f"show_favorite_teams_only={self.show_favorite_teams_only}, " + f"favorite_teams={self.favorite_teams}" + ) self.logger.debug( f"[LIVE_PRIORITY_DEBUG] {self.sport_key.upper()} filter decision for {game_str}: " diff --git a/plugins/baseball-scoreboard/test_kickoff_offer_follows_the_live_filter.py b/plugins/baseball-scoreboard/test_kickoff_offer_follows_the_live_filter.py new file mode 100644 index 00000000..110ed03d --- /dev/null +++ b/plugins/baseball-scoreboard/test_kickoff_offer_follows_the_live_filter.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Tests that the live loop offers the idle back-off only the kickoffs it would show. + +The core mixin clamps the idle back-off to the next known kickoff and then +holds the live cadence for a quarter of an hour after it +(_clamp_to_scheduled_start / _note_scheduled_start_candidate, tested in the +core's test_sports_shared.py). It learns the kickoffs from this loop, which +offers the games the live fetch already downloaded. What is pinned here is +which games it offers: + + * every game the board would show once it is live -- a favourites-only + board offers only its favourites' games, and an excluded team's game is + never offered. Each kickoff holds the poll at live cadence for 15 + minutes, so offering games the board then filters out kept a + favourites-only board polling the whole scoreboard all day on a busy + slate (football-scoreboard 3.18.7 found it; this is the same rule); + * a board that shows every live game is unchanged: it offers every game, + upcoming ones included, even when nothing is live; + * the call is getattr-guarded, so a core without the method still loads. + +Built on the same real-SportsLive scaffolding as football-scoreboard's +test_kickoff_wakes_the_idle_poll.py. + +Run: /bin/python plugins/baseball-scoreboard/test_kickoff_offer_follows_the_live_filter.py +""" + +# A test harness: it reaches into protected members on purpose, builds a +# concrete subclass at runtime, and accepts arguments only to match the +# signatures it stands in for -- none of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +# pylint: disable=broad-exception-caught + +import os +import sys +import threading +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +_core = os.environ.get("LEDMATRIX_CORE") +if _core and (Path(_core) / "src").is_dir(): + sys.path.insert(0, _core) + +try: + import sports # noqa: E402 +except ImportError as exc: # no core checkout on the path + print("SKIP: cannot import sports.py without a LEDMatrix core (%s)" % exc) + sys.exit(2) + +SPORT_KEY = "mlb" +#: The keys a game's two sides are matched on by this plugin's live filter. +HOME, AWAY = "home_abbr", "away_abbr" +FAV, FAV_OPP, OTHER_H, OTHER_A = ("NYY", "BOS", "LAD", "SD") +#: The abbreviation each side shows, where it is not the key itself. +ABBR = {} + + +class _Logger: + def info(self, *a, **k): pass + def debug(self, *a, **k): pass + def warning(self, *a, **k): pass + def error(self, *a, **k): pass + def exception(self, *a, **k): pass + + +class _StubCore: + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = {} + + +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(): + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete({}, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +def _prime(live): + """The SportsCore/SportsLive state update() reads, which _StubCore skips.""" + live.is_enabled = True + live.live_games = [] + live.last_update = 0 + live.show_ranking = False + live.test_mode = False + live.no_data_interval = 300 + live.update_interval = 30 + live.live_idle_max_interval = 900 + live._empty_live_streak = 0 + live.sport_key = SPORT_KEY + live.league = SPORT_KEY + live.sport = SPORT_KEY + live._games_lock = threading.RLock() + live.favorite_teams = [] + live.exclude_teams = [] + live.show_all_live = True + live.show_favorite_teams_only = False + live.tournament_mode = False + live.show_odds = False + live._rotation_schedule = [] + live.current_game_index = 0 + live.current_game = None + live.last_game_switch = 0 + live.game_update_timestamps = {} + live.last_log_time = 0 + live.log_interval = 300 + live.stale_game_timeout = 600 + live._check_for_score = lambda *a, **k: None + return live + + +def _drive(events, **settings): + """Run one update() pass over a canned payload; return the offered ids.""" + live = _prime(_live()) + for name, value in settings.items(): + setattr(live, name, value) + offered = [] + live._fetch_data = lambda *a, **k: {"events": list(events)} + live._extract_game_details = lambda game: dict(game) + live._note_scheduled_start_candidate = lambda details: offered.append(details) + try: + live.update() + except Exception as exc: # noqa: BLE001 + print(" (update raised: %r)" % (exc,)) + return sorted(d.get("id") for d in offered if isinstance(d, dict)), live + + +def _game(gid, home, away, **extra): + game = {"id": gid, HOME: home, AWAY: away, "home_abbr": ABBR.get(home, home), + "away_abbr": ABBR.get(away, away), "is_live": False, "is_halftime": False, + "is_final": False} + game.update(extra) + return game + + +def main(): + games = [_game("fav", FAV, FAV_OPP), _game("other", OTHER_H, OTHER_A)] + + print("a board showing every live game offers every game") + ids, live = _drive(games) + check("show_all_live offers every game, upcoming ones included", + ids == ["fav", "other"]) + check("and no game was treated as live", live.live_games == []) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=True, + show_favorite_teams_only=True) + check("show_all_live still offers every game with favourites set", + ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=False) + check("favourites-only off offers every game", ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[], show_all_live=False, + show_favorite_teams_only=True) + check("favourites-only with no favourites offers every game", + ids == ["fav", "other"]) + + print("\na favourites-only board offers only its favourites' games") + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=True) + check("only the favourite's game was offered (%s)" % ids, ids == ["fav"]) + ids, _ = _drive(games, exclude_teams=[OTHER_A]) + check("an excluded team's game is never offered (%s)" % ids, ids == ["fav"]) + print("\nthe call is getattr-guarded") + live2 = _prime(_live()) + live2._note_scheduled_start_candidate = None + live2._fetch_data = lambda *a, **k: {"events": [games[0]]} + live2._extract_game_details = lambda game: dict(game) + raised = None + try: + live2.update() + except Exception as exc: # noqa: BLE001 + raised = exc + check("a core without the method does not raise (%r)" % (raised,), raised is None) + + if failures: + print("\n%d check(s) failed" % len(failures)) + return 1 + print("\nall checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/basketball-scoreboard/CHANGELOG.md b/plugins/basketball-scoreboard/CHANGELOG.md index ba153d51..514c9331 100644 --- a/plugins/basketball-scoreboard/CHANGELOG.md +++ b/plugins/basketball-scoreboard/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.44.1] - 2026-10-05 + +### Fixed +- A board showing only its favourites' live games no longer wakes for other + teams' games. The live loop handed every game on the scoreboard to core's + idle back-off as a start to wake for, and each one holds the poll at the + live update interval for 15 minutes, so on a busy slate the board fetched + the whole scoreboard about every 30 s for games it then filtered out. Only + games the live filter would show are offered now (football-scoreboard 3.18.7 + made the same change). Excluded teams' games are never offered. Boards + showing every live game are unchanged. A tournament game in tournament mode + is still offered, as it is shown. + `test_kickoff_offer_follows_the_live_filter.py` covers the filter. + ## [1.44.0] - 2026-10-05 ### Changed diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 4abc51dd..d7e9d604 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.44.0", + "version": "1.44.1", "update_interval": 60, "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", @@ -19,6 +19,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.44.1", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.1", + "notes": "A favourites-only board no longer wakes for other teams' games. Every game the live fetch saw held the live poll at 30 s for 15 minutes, so on a busy slate it fetched the whole scoreboard about every 30 s for games it then filtered out. Only games the live filter would show are offered now, and excluded teams are honoured the same way. Boards that show every live game are unchanged." + }, { "version": "1.44.0", "released": "2026-10-05", diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 9e61bc8a..a261041a 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -3514,9 +3514,19 @@ def update(self): # starts, so it cannot sleep through a kickoff. # getattr-guarded: the core version floor is # advisory, so an older core must stay loadable. + # + # Only a game this board would show once it is live. + # Every kickoff holds the poll at the live cadence for a + # quarter of an hour, so offering the games the live + # filter then drops kept a favourites-only board polling + # the whole scoreboard all day (football 3.18.7). _note_start = getattr( self, "_note_scheduled_start_candidate", None) - if _note_start is not None: + if (_note_start is not None and details + and self._classify_live_game( + details.get("home_abbr"), + details.get("away_abbr"), + is_tournament=details.get("is_tournament", False))): _note_start(details) if details: # Log game status for debugging diff --git a/plugins/basketball-scoreboard/test_kickoff_offer_follows_the_live_filter.py b/plugins/basketball-scoreboard/test_kickoff_offer_follows_the_live_filter.py new file mode 100644 index 00000000..ea5143a6 --- /dev/null +++ b/plugins/basketball-scoreboard/test_kickoff_offer_follows_the_live_filter.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +""" +Tests that the live loop offers the idle back-off only the kickoffs it would show. + +The core mixin clamps the idle back-off to the next known kickoff and then +holds the live cadence for a quarter of an hour after it +(_clamp_to_scheduled_start / _note_scheduled_start_candidate, tested in the +core's test_sports_shared.py). It learns the kickoffs from this loop, which +offers the games the live fetch already downloaded. What is pinned here is +which games it offers: + + * every game the board would show once it is live -- a favourites-only + board offers only its favourites' games, and an excluded team's game is + never offered. Each kickoff holds the poll at live cadence for 15 + minutes, so offering games the board then filters out kept a + favourites-only board polling the whole scoreboard all day on a busy + slate (football-scoreboard 3.18.7 found it; this is the same rule); + * a board that shows every live game is unchanged: it offers every game, + upcoming ones included, even when nothing is live; + * the call is getattr-guarded, so a core without the method still loads. + +Built on the same real-SportsLive scaffolding as football-scoreboard's +test_kickoff_wakes_the_idle_poll.py. + +Run: /bin/python plugins/basketball-scoreboard/test_kickoff_offer_follows_the_live_filter.py +""" + +# A test harness: it reaches into protected members on purpose, builds a +# concrete subclass at runtime, and accepts arguments only to match the +# signatures it stands in for -- none of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +# pylint: disable=broad-exception-caught + +import os +import sys +import threading +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +_core = os.environ.get("LEDMATRIX_CORE") +if _core and (Path(_core) / "src").is_dir(): + sys.path.insert(0, _core) + +try: + import sports # noqa: E402 +except ImportError as exc: # no core checkout on the path + print("SKIP: cannot import sports.py without a LEDMatrix core (%s)" % exc) + sys.exit(2) + +SPORT_KEY = "nba" +#: The keys a game's two sides are matched on by this plugin's live filter. +HOME, AWAY = "home_abbr", "away_abbr" +FAV, FAV_OPP, OTHER_H, OTHER_A = ("BOS", "NY", "LAL", "GS") +#: The abbreviation each side shows, where it is not the key itself. +ABBR = {} + + +class _Logger: + def info(self, *a, **k): pass + def debug(self, *a, **k): pass + def warning(self, *a, **k): pass + def error(self, *a, **k): pass + def exception(self, *a, **k): pass + + +class _StubCore: + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = {} + + +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(): + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete({}, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +def _prime(live): + """The SportsCore/SportsLive state update() reads, which _StubCore skips.""" + live.is_enabled = True + live.live_games = [] + live.last_update = 0 + live.show_ranking = False + live.test_mode = False + live.no_data_interval = 300 + live.update_interval = 30 + live.live_idle_max_interval = 900 + live._empty_live_streak = 0 + live.sport_key = SPORT_KEY + live.league = SPORT_KEY + live.sport = SPORT_KEY + live._games_lock = threading.RLock() + live.favorite_teams = [] + live.exclude_teams = [] + live.show_all_live = True + live.show_favorite_teams_only = False + live.tournament_mode = False + live.show_odds = False + live._rotation_schedule = [] + live.current_game_index = 0 + live.current_game = None + live.last_game_switch = 0 + live.game_update_timestamps = {} + live.last_log_time = 0 + live.log_interval = 300 + live.stale_game_timeout = 600 + live._check_for_score = lambda *a, **k: None + return live + + +def _drive(events, **settings): + """Run one update() pass over a canned payload; return the offered ids.""" + live = _prime(_live()) + for name, value in settings.items(): + setattr(live, name, value) + offered = [] + live._fetch_data = lambda *a, **k: {"events": list(events)} + live._extract_game_details = lambda game: dict(game) + live._note_scheduled_start_candidate = lambda details: offered.append(details) + try: + live.update() + except Exception as exc: # noqa: BLE001 + print(" (update raised: %r)" % (exc,)) + return sorted(d.get("id") for d in offered if isinstance(d, dict)), live + + +def _game(gid, home, away, **extra): + game = {"id": gid, HOME: home, AWAY: away, "home_abbr": ABBR.get(home, home), + "away_abbr": ABBR.get(away, away), "is_live": False, "is_halftime": False, + "is_final": False} + game.update(extra) + return game + + +def main(): + games = [_game("fav", FAV, FAV_OPP), _game("other", OTHER_H, OTHER_A)] + + print("a board showing every live game offers every game") + ids, live = _drive(games) + check("show_all_live offers every game, upcoming ones included", + ids == ["fav", "other"]) + check("and no game was treated as live", live.live_games == []) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=True, + show_favorite_teams_only=True) + check("show_all_live still offers every game with favourites set", + ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=False) + check("favourites-only off offers every game", ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[], show_all_live=False, + show_favorite_teams_only=True) + check("favourites-only with no favourites offers every game", + ids == ["fav", "other"]) + + print("\na favourites-only board offers only its favourites' games") + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=True) + check("only the favourite's game was offered (%s)" % ids, ids == ["fav"]) + ids, _ = _drive(games, exclude_teams=[OTHER_A]) + check("an excluded team's game is never offered (%s)" % ids, ids == ["fav"]) + + print("\ntournament mode shows every tournament game, so offers it") + tourney = [_game("fav", FAV, FAV_OPP), + _game("tourney", OTHER_H, OTHER_A, is_tournament=True), + _game("other", "MIA", "CHI")] + ids, _ = _drive(tourney, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=True, tournament_mode=True) + check("a tournament game is offered on a favourites-only board (%s)" % ids, + ids == ["fav", "tourney"]) + ids, _ = _drive(tourney, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=True, tournament_mode=True, + exclude_teams=[OTHER_A]) + check("unless an excluded team plays in it (%s)" % ids, ids == ["fav"]) + print("\nthe call is getattr-guarded") + live2 = _prime(_live()) + live2._note_scheduled_start_candidate = None + live2._fetch_data = lambda *a, **k: {"events": [games[0]]} + live2._extract_game_details = lambda game: dict(game) + raised = None + try: + live2.update() + except Exception as exc: # noqa: BLE001 + raised = exc + check("a core without the method does not raise (%r)" % (raised,), raised is None) + + if failures: + print("\n%d check(s) failed" % len(failures)) + return 1 + print("\nall checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/hockey-scoreboard/CHANGELOG.md b/plugins/hockey-scoreboard/CHANGELOG.md index fe47e2c7..edc9d715 100644 --- a/plugins/hockey-scoreboard/CHANGELOG.md +++ b/plugins/hockey-scoreboard/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [1.44.1] - 2026-10-05 + +### Fixed +- A board showing only its favourites' live games no longer wakes for other + teams' games. The live loop handed every game on the scoreboard to core's + idle back-off as a start to wake for, and each one holds the poll at the + live update interval for 15 minutes, so on a busy slate the board fetched + the whole scoreboard about every 30 s for games it then filtered out. Only + games the live filter would show are offered now (football-scoreboard 3.18.7 + made the same change). Excluded teams' games are never offered. Boards + showing every live game are unchanged. Replaying Saturday 2026-10-03 under + frozen time (09:00-03:00 ET, NHL preseason): favourite LA (one 22:00 game) + 645 -> 513 scoreboard requests, 12.7 -> 10.1 MB on the wire (192 -> 155 MB + decoded). Show-all: the same requests and the same games shown. + `test_kickoff_offer_follows_the_live_filter.py` covers the filter. + ## [1.44.0] - 2026-10-05 ### Changed diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 526f1751..91470a33 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.44.0", + "version": "1.44.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": [ + { + "version": "1.44.1", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.1", + "notes": "A favourites-only board no longer wakes for other teams' games. Every game the live fetch saw held the live poll at 30 s for 15 minutes, so on a busy slate it fetched the whole scoreboard about every 30 s for games it then filtered out. Only games the live filter would show are offered now, and excluded teams are honoured the same way. Replaying Saturday 2026-10-03 (09:00-03:00 ET, favourite LA): 645 -> 513 scoreboard requests, 12.7 -> 10.1 MB on the wire (192 -> 155 MB decoded). Boards that show every live game are unchanged." + }, { "version": "1.44.0", "released": "2026-10-05", diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 23b3075e..2a26d420 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -3090,9 +3090,18 @@ def update(self): # starts, so it cannot sleep through a kickoff. # getattr-guarded: the core version floor is # advisory, so an older core must stay loadable. + # + # Only a game this board would show once it is live. + # Every kickoff holds the poll at the live cadence for a + # quarter of an hour, so offering the games the live + # filter then drops kept a favourites-only board polling + # the whole scoreboard all day (football 3.18.7). _note_start = getattr( self, "_note_scheduled_start_candidate", None) - if _note_start is not None: + if (_note_start is not None and details + and self._is_live_game_included( + details.get("home_abbr"), + details.get("away_abbr"))): _note_start(details) if details: # Filter out final games and games that appear to diff --git a/plugins/hockey-scoreboard/test_kickoff_offer_follows_the_live_filter.py b/plugins/hockey-scoreboard/test_kickoff_offer_follows_the_live_filter.py new file mode 100644 index 00000000..9481ed89 --- /dev/null +++ b/plugins/hockey-scoreboard/test_kickoff_offer_follows_the_live_filter.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Tests that the live loop offers the idle back-off only the kickoffs it would show. + +The core mixin clamps the idle back-off to the next known kickoff and then +holds the live cadence for a quarter of an hour after it +(_clamp_to_scheduled_start / _note_scheduled_start_candidate, tested in the +core's test_sports_shared.py). It learns the kickoffs from this loop, which +offers the games the live fetch already downloaded. What is pinned here is +which games it offers: + + * every game the board would show once it is live -- a favourites-only + board offers only its favourites' games, and an excluded team's game is + never offered. Each kickoff holds the poll at live cadence for 15 + minutes, so offering games the board then filters out kept a + favourites-only board polling the whole scoreboard all day on a busy + slate (football-scoreboard 3.18.7 found it; this is the same rule); + * a board that shows every live game is unchanged: it offers every game, + upcoming ones included, even when nothing is live; + * the call is getattr-guarded, so a core without the method still loads. + +Built on the same real-SportsLive scaffolding as football-scoreboard's +test_kickoff_wakes_the_idle_poll.py. + +Run: /bin/python plugins/hockey-scoreboard/test_kickoff_offer_follows_the_live_filter.py +""" + +# A test harness: it reaches into protected members on purpose, builds a +# concrete subclass at runtime, and accepts arguments only to match the +# signatures it stands in for -- none of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +# pylint: disable=broad-exception-caught + +import os +import sys +import threading +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +_core = os.environ.get("LEDMATRIX_CORE") +if _core and (Path(_core) / "src").is_dir(): + sys.path.insert(0, _core) + +try: + import sports # noqa: E402 +except ImportError as exc: # no core checkout on the path + print("SKIP: cannot import sports.py without a LEDMatrix core (%s)" % exc) + sys.exit(2) + +SPORT_KEY = "nhl" +#: The keys a game's two sides are matched on by this plugin's live filter. +HOME, AWAY = "home_abbr", "away_abbr" +FAV, FAV_OPP, OTHER_H, OTHER_A = ("TB", "FLA", "NYR", "NJ") +#: The abbreviation each side shows, where it is not the key itself. +ABBR = {} + + +class _Logger: + def info(self, *a, **k): pass + def debug(self, *a, **k): pass + def warning(self, *a, **k): pass + def error(self, *a, **k): pass + def exception(self, *a, **k): pass + + +class _StubCore: + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = {} + + +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(): + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete({}, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +def _prime(live): + """The SportsCore/SportsLive state update() reads, which _StubCore skips.""" + live.is_enabled = True + live.live_games = [] + live.last_update = 0 + live.show_ranking = False + live.test_mode = False + live.no_data_interval = 300 + live.update_interval = 30 + live.live_idle_max_interval = 900 + live._empty_live_streak = 0 + live.sport_key = SPORT_KEY + live.league = SPORT_KEY + live.sport = SPORT_KEY + live._games_lock = threading.RLock() + live.favorite_teams = [] + live.exclude_teams = [] + live.show_all_live = True + live.show_favorite_teams_only = False + live.tournament_mode = False + live.show_odds = False + live._rotation_schedule = [] + live.current_game_index = 0 + live.current_game = None + live.last_game_switch = 0 + live.game_update_timestamps = {} + live.last_log_time = 0 + live.log_interval = 300 + live.stale_game_timeout = 600 + live._check_for_score = lambda *a, **k: None + return live + + +def _drive(events, **settings): + """Run one update() pass over a canned payload; return the offered ids.""" + live = _prime(_live()) + for name, value in settings.items(): + setattr(live, name, value) + offered = [] + live._fetch_data = lambda *a, **k: {"events": list(events)} + live._extract_game_details = lambda game: dict(game) + live._note_scheduled_start_candidate = lambda details: offered.append(details) + try: + live.update() + except Exception as exc: # noqa: BLE001 + print(" (update raised: %r)" % (exc,)) + return sorted(d.get("id") for d in offered if isinstance(d, dict)), live + + +def _game(gid, home, away, **extra): + game = {"id": gid, HOME: home, AWAY: away, "home_abbr": ABBR.get(home, home), + "away_abbr": ABBR.get(away, away), "is_live": False, "is_halftime": False, + "is_final": False} + game.update(extra) + return game + + +def main(): + games = [_game("fav", FAV, FAV_OPP), _game("other", OTHER_H, OTHER_A)] + + print("a board showing every live game offers every game") + ids, live = _drive(games) + check("show_all_live offers every game, upcoming ones included", + ids == ["fav", "other"]) + check("and no game was treated as live", live.live_games == []) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=True, + show_favorite_teams_only=True) + check("show_all_live still offers every game with favourites set", + ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=False) + check("favourites-only off offers every game", ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[], show_all_live=False, + show_favorite_teams_only=True) + check("favourites-only with no favourites offers every game", + ids == ["fav", "other"]) + + print("\na favourites-only board offers only its favourites' games") + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=True) + check("only the favourite's game was offered (%s)" % ids, ids == ["fav"]) + ids, _ = _drive(games, exclude_teams=[OTHER_A]) + check("an excluded team's game is never offered (%s)" % ids, ids == ["fav"]) + print("\nthe call is getattr-guarded") + live2 = _prime(_live()) + live2._note_scheduled_start_candidate = None + live2._fetch_data = lambda *a, **k: {"events": [games[0]]} + live2._extract_game_details = lambda game: dict(game) + raised = None + try: + live2.update() + except Exception as exc: # noqa: BLE001 + raised = exc + check("a core without the method does not raise (%r)" % (raised,), raised is None) + + if failures: + print("\n%d check(s) failed" % len(failures)) + return 1 + print("\nall checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/lacrosse-scoreboard/CHANGELOG.md b/plugins/lacrosse-scoreboard/CHANGELOG.md index c5453c12..36276bd1 100644 --- a/plugins/lacrosse-scoreboard/CHANGELOG.md +++ b/plugins/lacrosse-scoreboard/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.37.1] - 2026-10-05 + +### Fixed +- A board showing only its favourites' live games no longer wakes for other + teams' games. The live loop handed every game on the scoreboard to core's + idle back-off as a start to wake for, and each one holds the poll at the + live update interval for 15 minutes, so on a busy slate the board fetched + the whole scoreboard about every 30 s for games it then filtered out. Only + games the live filter would show are offered now (football-scoreboard 3.18.7 + made the same change). Excluded teams' games are never offered. Boards + showing every live game are unchanged. The live filter moved out of + `update()` into `_classify_live_game()`, as in the other scoreboards, so the + live list and the kickoff offer share one rule. + `test_kickoff_offer_follows_the_live_filter.py` covers the filter. + ## [1.37.0] - 2026-10-05 ### Changed diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index d79f56bd..486faccd 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.37.0", + "version": "1.37.1", "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": [ + { + "version": "1.37.1", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.1", + "notes": "A favourites-only board no longer wakes for other teams' games. Every game the live fetch saw held the live poll at 30 s for 15 minutes, so on a busy slate it fetched the whole scoreboard about every 30 s for games it then filtered out. Only games the live filter would show are offered now, and excluded teams are honoured the same way. Boards that show every live game are unchanged." + }, { "version": "1.37.0", "released": "2026-10-05", diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index 8f3ba70b..2f160434 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -2925,6 +2925,23 @@ def _is_favorite_game(self, game) -> bool: or game.get("away_abbr") in self.favorite_teams ) + def _classify_live_game(self, home_abbr, away_abbr) -> bool: + """Whether a live game should be included in the live rotation. + + Priority: excluded team (never shown, overrides everything) > + show_all_live > show_favorite_teams_only disabled > no favorites + configured (fallback: show all) > favorite-teams-only membership. + """ + if home_abbr in self.exclude_teams or away_abbr in self.exclude_teams: + return False + if self.show_all_live: + return True + if not self.show_favorite_teams_only: + return True + if not self.favorite_teams: + return True + return home_abbr in self.favorite_teams or away_abbr in self.favorite_teams + def _keep_final_for_vegas(self, details: Dict) -> None: """Hold a game that just went final, so its live Vegas card shows Final. @@ -3014,9 +3031,18 @@ def update(self): # starts, so it cannot sleep through a kickoff. # getattr-guarded: the core version floor is # advisory, so an older core must stay loadable. + # + # Only a game this board would show once it is live. + # Every kickoff holds the poll at the live cadence for a + # quarter of an hour, so offering the games the live + # filter then drops kept a favourites-only board polling + # the whole scoreboard all day (football 3.18.7). _note_start = getattr( self, "_note_scheduled_start_candidate", None) - if _note_start is not None: + if (_note_start is not None and details + and self._classify_live_game( + details.get("home_abbr"), + details.get("away_abbr"))): _note_start(details) if details: # Filter out final games and games that appear to be over @@ -3043,35 +3069,12 @@ def update(self): live_or_halftime_count += 1 - # Filtering logic matching SportsUpcoming: - # - If show_all_live = True → show all games - # - If show_favorite_teams_only = False → show all games - # - If show_favorite_teams_only = True but favorite_teams is empty → show all games (fallback) - # - If show_favorite_teams_only = True and favorite_teams has teams → only show games with those teams - if ( - details["home_abbr"] in self.exclude_teams - or details["away_abbr"] in self.exclude_teams - ): - # Excluded teams are always hidden, regardless of - # every other filter (spoiler protection) - should_include = False - elif self.show_all_live: - # Always show all live games if show_all_live is enabled - should_include = True - elif not self.show_favorite_teams_only: - # If favorite teams filtering is disabled, show all games - should_include = True - elif not self.favorite_teams: - # If favorite teams filtering is enabled but no favorites are configured, - # show all games (same behavior as SportsUpcoming) - should_include = True - else: - # Favorite teams filtering is enabled AND favorites are configured - # Only show games involving favorite teams - should_include = ( - details["home_abbr"] in self.favorite_teams - or details["away_abbr"] in self.favorite_teams - ) + # Filtering logic (see _classify_live_game for the + # full precedence order: exclude > show_all_live > + # favorites-only membership; matches SportsUpcoming). + should_include = self._classify_live_game( + details["home_abbr"], details["away_abbr"] + ) if not should_include: filtered_out_count += 1 diff --git a/plugins/lacrosse-scoreboard/test_kickoff_offer_follows_the_live_filter.py b/plugins/lacrosse-scoreboard/test_kickoff_offer_follows_the_live_filter.py new file mode 100644 index 00000000..28a06095 --- /dev/null +++ b/plugins/lacrosse-scoreboard/test_kickoff_offer_follows_the_live_filter.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Tests that the live loop offers the idle back-off only the kickoffs it would show. + +The core mixin clamps the idle back-off to the next known kickoff and then +holds the live cadence for a quarter of an hour after it +(_clamp_to_scheduled_start / _note_scheduled_start_candidate, tested in the +core's test_sports_shared.py). It learns the kickoffs from this loop, which +offers the games the live fetch already downloaded. What is pinned here is +which games it offers: + + * every game the board would show once it is live -- a favourites-only + board offers only its favourites' games, and an excluded team's game is + never offered. Each kickoff holds the poll at live cadence for 15 + minutes, so offering games the board then filters out kept a + favourites-only board polling the whole scoreboard all day on a busy + slate (football-scoreboard 3.18.7 found it; this is the same rule); + * a board that shows every live game is unchanged: it offers every game, + upcoming ones included, even when nothing is live; + * the call is getattr-guarded, so a core without the method still loads. + +Built on the same real-SportsLive scaffolding as football-scoreboard's +test_kickoff_wakes_the_idle_poll.py. + +Run: /bin/python plugins/lacrosse-scoreboard/test_kickoff_offer_follows_the_live_filter.py +""" + +# A test harness: it reaches into protected members on purpose, builds a +# concrete subclass at runtime, and accepts arguments only to match the +# signatures it stands in for -- none of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +# pylint: disable=broad-exception-caught + +import os +import sys +import threading +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +_core = os.environ.get("LEDMATRIX_CORE") +if _core and (Path(_core) / "src").is_dir(): + sys.path.insert(0, _core) + +try: + import sports # noqa: E402 +except ImportError as exc: # no core checkout on the path + print("SKIP: cannot import sports.py without a LEDMatrix core (%s)" % exc) + sys.exit(2) + +SPORT_KEY = "ncaam_lacrosse" +#: The keys a game's two sides are matched on by this plugin's live filter. +HOME, AWAY = "home_abbr", "away_abbr" +FAV, FAV_OPP, OTHER_H, OTHER_A = ("SYR", "DUKE", "UVA", "ND") +#: The abbreviation each side shows, where it is not the key itself. +ABBR = {} + + +class _Logger: + def info(self, *a, **k): pass + def debug(self, *a, **k): pass + def warning(self, *a, **k): pass + def error(self, *a, **k): pass + def exception(self, *a, **k): pass + + +class _StubCore: + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = {} + + +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(): + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete({}, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +def _prime(live): + """The SportsCore/SportsLive state update() reads, which _StubCore skips.""" + live.is_enabled = True + live.live_games = [] + live.last_update = 0 + live.show_ranking = False + live.test_mode = False + live.no_data_interval = 300 + live.update_interval = 30 + live.live_idle_max_interval = 900 + live._empty_live_streak = 0 + live.sport_key = SPORT_KEY + live.league = SPORT_KEY + live.sport = SPORT_KEY + live._games_lock = threading.RLock() + live.favorite_teams = [] + live.exclude_teams = [] + live.show_all_live = True + live.show_favorite_teams_only = False + live.tournament_mode = False + live.show_odds = False + live._rotation_schedule = [] + live.current_game_index = 0 + live.current_game = None + live.last_game_switch = 0 + live.game_update_timestamps = {} + live.last_log_time = 0 + live.log_interval = 300 + live.stale_game_timeout = 600 + live._check_for_score = lambda *a, **k: None + return live + + +def _drive(events, **settings): + """Run one update() pass over a canned payload; return the offered ids.""" + live = _prime(_live()) + for name, value in settings.items(): + setattr(live, name, value) + offered = [] + live._fetch_data = lambda *a, **k: {"events": list(events)} + live._extract_game_details = lambda game: dict(game) + live._note_scheduled_start_candidate = lambda details: offered.append(details) + try: + live.update() + except Exception as exc: # noqa: BLE001 + print(" (update raised: %r)" % (exc,)) + return sorted(d.get("id") for d in offered if isinstance(d, dict)), live + + +def _game(gid, home, away, **extra): + game = {"id": gid, HOME: home, AWAY: away, "home_abbr": ABBR.get(home, home), + "away_abbr": ABBR.get(away, away), "is_live": False, "is_halftime": False, + "is_final": False} + game.update(extra) + return game + + +def main(): + games = [_game("fav", FAV, FAV_OPP), _game("other", OTHER_H, OTHER_A)] + + print("a board showing every live game offers every game") + ids, live = _drive(games) + check("show_all_live offers every game, upcoming ones included", + ids == ["fav", "other"]) + check("and no game was treated as live", live.live_games == []) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=True, + show_favorite_teams_only=True) + check("show_all_live still offers every game with favourites set", + ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=False) + check("favourites-only off offers every game", ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[], show_all_live=False, + show_favorite_teams_only=True) + check("favourites-only with no favourites offers every game", + ids == ["fav", "other"]) + + print("\na favourites-only board offers only its favourites' games") + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=True) + check("only the favourite's game was offered (%s)" % ids, ids == ["fav"]) + ids, _ = _drive(games, exclude_teams=[OTHER_A]) + check("an excluded team's game is never offered (%s)" % ids, ids == ["fav"]) + print("\nthe call is getattr-guarded") + live2 = _prime(_live()) + live2._note_scheduled_start_candidate = None + live2._fetch_data = lambda *a, **k: {"events": [games[0]]} + live2._extract_game_details = lambda game: dict(game) + raised = None + try: + live2.update() + except Exception as exc: # noqa: BLE001 + raised = exc + check("a core without the method does not raise (%r)" % (raised,), raised is None) + + if failures: + print("\n%d check(s) failed" % len(failures)) + return 1 + print("\nall checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/nrl-scoreboard/CHANGELOG.md b/plugins/nrl-scoreboard/CHANGELOG.md index d841bd5f..40622571 100644 --- a/plugins/nrl-scoreboard/CHANGELOG.md +++ b/plugins/nrl-scoreboard/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.36.1] - 2026-10-05 + +### Fixed +- A board showing only its favourites' live games no longer wakes for other + teams' games. The live loop handed every game on the scoreboard to core's + idle back-off as a start to wake for, and each one holds the poll at the + live update interval for 15 minutes, so on a busy slate the board fetched + the whole scoreboard about every 30 s for games it then filtered out. Only + games the live filter would show are offered now (football-scoreboard 3.18.7 + made the same change). Excluded teams' games are never offered. Boards + showing every live game are unchanged. + `test_kickoff_offer_follows_the_live_filter.py` covers the filter. + ## [1.36.0] - 2026-10-05 ### Changed diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 939e630e..9aad201f 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.36.0", + "version": "1.36.1", "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": [ + { + "version": "1.36.1", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.1", + "notes": "A favourites-only board no longer wakes for other teams' games. Every game the live fetch saw held the live poll at 30 s for 15 minutes, so on a busy slate it fetched the whole scoreboard about every 30 s for games it then filtered out. Only games the live filter would show are offered now, and excluded teams are honoured the same way. Boards that show every live game are unchanged." + }, { "version": "1.36.0", "released": "2026-10-05", diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index 4feb2aac..2b5a38c3 100644 --- a/plugins/nrl-scoreboard/sports.py +++ b/plugins/nrl-scoreboard/sports.py @@ -3734,9 +3734,18 @@ def update(self): # starts, so it cannot sleep through a kickoff. # getattr-guarded: the core version floor is # advisory, so an older core must stay loadable. + # + # Only a game this board would show once it is live. + # Every kickoff holds the poll at the live cadence for a + # quarter of an hour, so offering the games the live + # filter then drops kept a favourites-only board polling + # the whole scoreboard all day (football 3.18.7). _note_start = getattr( self, "_note_scheduled_start_candidate", None) - if _note_start is not None: + if (_note_start is not None and details + and self._classify_live_game( + details.get("home_id"), + details.get("away_id"))): _note_start(details) if details: # Log game status for debugging diff --git a/plugins/nrl-scoreboard/test_kickoff_offer_follows_the_live_filter.py b/plugins/nrl-scoreboard/test_kickoff_offer_follows_the_live_filter.py new file mode 100644 index 00000000..bf387165 --- /dev/null +++ b/plugins/nrl-scoreboard/test_kickoff_offer_follows_the_live_filter.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Tests that the live loop offers the idle back-off only the kickoffs it would show. + +The core mixin clamps the idle back-off to the next known kickoff and then +holds the live cadence for a quarter of an hour after it +(_clamp_to_scheduled_start / _note_scheduled_start_candidate, tested in the +core's test_sports_shared.py). It learns the kickoffs from this loop, which +offers the games the live fetch already downloaded. What is pinned here is +which games it offers: + + * every game the board would show once it is live -- a favourites-only + board offers only its favourites' games, and an excluded team's game is + never offered. Each kickoff holds the poll at live cadence for 15 + minutes, so offering games the board then filters out kept a + favourites-only board polling the whole scoreboard all day on a busy + slate (football-scoreboard 3.18.7 found it; this is the same rule); + * a board that shows every live game is unchanged: it offers every game, + upcoming ones included, even when nothing is live; + * the call is getattr-guarded, so a core without the method still loads. + +Built on the same real-SportsLive scaffolding as football-scoreboard's +test_kickoff_wakes_the_idle_poll.py. + +Run: /bin/python plugins/nrl-scoreboard/test_kickoff_offer_follows_the_live_filter.py +""" + +# A test harness: it reaches into protected members on purpose, builds a +# concrete subclass at runtime, and accepts arguments only to match the +# signatures it stands in for -- none of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +# pylint: disable=broad-exception-caught + +import os +import sys +import threading +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +_core = os.environ.get("LEDMATRIX_CORE") +if _core and (Path(_core) / "src").is_dir(): + sys.path.insert(0, _core) + +try: + import sports # noqa: E402 +except ImportError as exc: # no core checkout on the path + print("SKIP: cannot import sports.py without a LEDMatrix core (%s)" % exc) + sys.exit(2) + +SPORT_KEY = "nrl" +#: The keys a game's two sides are matched on by this plugin's live filter. +HOME, AWAY = "home_id", "away_id" +FAV, FAV_OPP, OTHER_H, OTHER_A = ("1", "2", "3", "4") +#: The abbreviation each side shows, where it is not the key itself. +ABBR = {"1": "NEW", "2": "CAN", "3": "NEW", "4": "CAN"} + + +class _Logger: + def info(self, *a, **k): pass + def debug(self, *a, **k): pass + def warning(self, *a, **k): pass + def error(self, *a, **k): pass + def exception(self, *a, **k): pass + + +class _StubCore: + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = {} + + +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(): + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete({}, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +def _prime(live): + """The SportsCore/SportsLive state update() reads, which _StubCore skips.""" + live.is_enabled = True + live.live_games = [] + live.last_update = 0 + live.show_ranking = False + live.test_mode = False + live.no_data_interval = 300 + live.update_interval = 30 + live.live_idle_max_interval = 900 + live._empty_live_streak = 0 + live.sport_key = SPORT_KEY + live.league = SPORT_KEY + live.sport = SPORT_KEY + live._games_lock = threading.RLock() + live.favorite_teams = [] + live.exclude_teams = [] + live.show_all_live = True + live.show_favorite_teams_only = False + live.tournament_mode = False + live.show_odds = False + live._rotation_schedule = [] + live.current_game_index = 0 + live.current_game = None + live.last_game_switch = 0 + live.game_update_timestamps = {} + live.last_log_time = 0 + live.log_interval = 300 + live.stale_game_timeout = 600 + live._check_for_score = lambda *a, **k: None + return live + + +def _drive(events, **settings): + """Run one update() pass over a canned payload; return the offered ids.""" + live = _prime(_live()) + for name, value in settings.items(): + setattr(live, name, value) + offered = [] + live._fetch_data = lambda *a, **k: {"events": list(events)} + live._extract_game_details = lambda game: dict(game) + live._note_scheduled_start_candidate = lambda details: offered.append(details) + try: + live.update() + except Exception as exc: # noqa: BLE001 + print(" (update raised: %r)" % (exc,)) + return sorted(d.get("id") for d in offered if isinstance(d, dict)), live + + +def _game(gid, home, away, **extra): + game = {"id": gid, HOME: home, AWAY: away, "home_abbr": ABBR.get(home, home), + "away_abbr": ABBR.get(away, away), "is_live": False, "is_halftime": False, + "is_final": False} + game.update(extra) + return game + + +def main(): + games = [_game("fav", FAV, FAV_OPP), _game("other", OTHER_H, OTHER_A)] + + print("a board showing every live game offers every game") + ids, live = _drive(games) + check("show_all_live offers every game, upcoming ones included", + ids == ["fav", "other"]) + check("and no game was treated as live", live.live_games == []) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=True, + show_favorite_teams_only=True) + check("show_all_live still offers every game with favourites set", + ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=False) + check("favourites-only off offers every game", ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[], show_all_live=False, + show_favorite_teams_only=True) + check("favourites-only with no favourites offers every game", + ids == ["fav", "other"]) + + print("\na favourites-only board offers only its favourites' games") + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=True) + check("only the favourite's game was offered (%s)" % ids, ids == ["fav"]) + ids, _ = _drive(games, exclude_teams=[OTHER_A]) + check("an excluded team's game is never offered (%s)" % ids, ids == ["fav"]) + print("\nthe call is getattr-guarded") + live2 = _prime(_live()) + live2._note_scheduled_start_candidate = None + live2._fetch_data = lambda *a, **k: {"events": [games[0]]} + live2._extract_game_details = lambda game: dict(game) + raised = None + try: + live2.update() + except Exception as exc: # noqa: BLE001 + raised = exc + check("a core without the method does not raise (%r)" % (raised,), raised is None) + + if failures: + print("\n%d check(s) failed" % len(failures)) + return 1 + print("\nall checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/soccer-scoreboard/CHANGELOG.md b/plugins/soccer-scoreboard/CHANGELOG.md index 9320382b..07c3cf0f 100644 --- a/plugins/soccer-scoreboard/CHANGELOG.md +++ b/plugins/soccer-scoreboard/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2.41.1] - 2026-10-05 + +### Fixed +- A board showing only its favourites' live matches no longer wakes for other + teams' matches. The live loop handed every match on the scoreboard to core's + idle back-off as a start to wake for, and each one holds the poll at the + live update interval for 15 minutes, so on a busy slate the board fetched + the whole scoreboard about every 30 s for matches it then filtered out. Only + matches the live filter would show are offered now (football-scoreboard + 3.18.7 made the same change). Excluded teams' matches are never offered. + Boards showing every live match are unchanged. + `test_kickoff_offer_follows_the_live_filter.py` covers the filter. + ## [2.41.0] - 2026-10-05 ### Changed diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 19a81c2e..1ed6c1f7 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.41.0", + "version": "2.41.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -53,6 +53,12 @@ "soccer_usa.1_upcoming" ], "versions": [ + { + "version": "2.41.1", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.1", + "notes": "A favourites-only board no longer wakes for other teams' matches. Every match the live fetch saw held the live poll at 30 s for 15 minutes, so on a busy slate it fetched the whole scoreboard about every 30 s for matches it then filtered out. Only matches the live filter would show are offered now, and excluded teams are honoured the same way. Boards that show every live game are unchanged." + }, { "version": "2.41.0", "released": "2026-10-05", diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index 0d149059..08b6436a 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -3653,9 +3653,18 @@ def update(self): # starts, so it cannot sleep through a kickoff. # getattr-guarded: the core version floor is # advisory, so an older core must stay loadable. + # + # Only a game this board would show once it is live. + # Every kickoff holds the poll at the live cadence for a + # quarter of an hour, so offering the games the live + # filter then drops kept a favourites-only board polling + # the whole scoreboard all day (football 3.18.7). _note_start = getattr( self, "_note_scheduled_start_candidate", None) - if _note_start is not None: + if (_note_start is not None and details + and self._classify_live_game( + details.get("home_abbr"), + details.get("away_abbr"))): _note_start(details) if details: # Log game status for debugging diff --git a/plugins/soccer-scoreboard/test_kickoff_offer_follows_the_live_filter.py b/plugins/soccer-scoreboard/test_kickoff_offer_follows_the_live_filter.py new file mode 100644 index 00000000..9e1d6099 --- /dev/null +++ b/plugins/soccer-scoreboard/test_kickoff_offer_follows_the_live_filter.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Tests that the live loop offers the idle back-off only the kickoffs it would show. + +The core mixin clamps the idle back-off to the next known kickoff and then +holds the live cadence for a quarter of an hour after it +(_clamp_to_scheduled_start / _note_scheduled_start_candidate, tested in the +core's test_sports_shared.py). It learns the kickoffs from this loop, which +offers the games the live fetch already downloaded. What is pinned here is +which games it offers: + + * every game the board would show once it is live -- a favourites-only + board offers only its favourites' games, and an excluded team's game is + never offered. Each kickoff holds the poll at live cadence for 15 + minutes, so offering games the board then filters out kept a + favourites-only board polling the whole scoreboard all day on a busy + slate (football-scoreboard 3.18.7 found it; this is the same rule); + * a board that shows every live game is unchanged: it offers every game, + upcoming ones included, even when nothing is live; + * the call is getattr-guarded, so a core without the method still loads. + +Built on the same real-SportsLive scaffolding as football-scoreboard's +test_kickoff_wakes_the_idle_poll.py. + +Run: /bin/python plugins/soccer-scoreboard/test_kickoff_offer_follows_the_live_filter.py +""" + +# A test harness: it reaches into protected members on purpose, builds a +# concrete subclass at runtime, and accepts arguments only to match the +# signatures it stands in for -- none of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +# pylint: disable=broad-exception-caught + +import os +import sys +import threading +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +_core = os.environ.get("LEDMATRIX_CORE") +if _core and (Path(_core) / "src").is_dir(): + sys.path.insert(0, _core) + +try: + import sports # noqa: E402 +except ImportError as exc: # no core checkout on the path + print("SKIP: cannot import sports.py without a LEDMatrix core (%s)" % exc) + sys.exit(2) + +SPORT_KEY = "eng.1" +#: The keys a game's two sides are matched on by this plugin's live filter. +HOME, AWAY = "home_abbr", "away_abbr" +FAV, FAV_OPP, OTHER_H, OTHER_A = ("ARS", "CHE", "LIV", "MCI") +#: The abbreviation each side shows, where it is not the key itself. +ABBR = {} + + +class _Logger: + def info(self, *a, **k): pass + def debug(self, *a, **k): pass + def warning(self, *a, **k): pass + def error(self, *a, **k): pass + def exception(self, *a, **k): pass + + +class _StubCore: + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = {} + + +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(): + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete({}, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +def _prime(live): + """The SportsCore/SportsLive state update() reads, which _StubCore skips.""" + live.is_enabled = True + live.live_games = [] + live.last_update = 0 + live.show_ranking = False + live.test_mode = False + live.no_data_interval = 300 + live.update_interval = 30 + live.live_idle_max_interval = 900 + live._empty_live_streak = 0 + live.sport_key = SPORT_KEY + live.league = SPORT_KEY + live.sport = SPORT_KEY + live._games_lock = threading.RLock() + live.favorite_teams = [] + live.exclude_teams = [] + live.show_all_live = True + live.show_favorite_teams_only = False + live.tournament_mode = False + live.show_odds = False + live._rotation_schedule = [] + live.current_game_index = 0 + live.current_game = None + live.last_game_switch = 0 + live.game_update_timestamps = {} + live.last_log_time = 0 + live.log_interval = 300 + live.stale_game_timeout = 600 + live._check_for_score = lambda *a, **k: None + return live + + +def _drive(events, **settings): + """Run one update() pass over a canned payload; return the offered ids.""" + live = _prime(_live()) + for name, value in settings.items(): + setattr(live, name, value) + offered = [] + live._fetch_data = lambda *a, **k: {"events": list(events)} + live._extract_game_details = lambda game: dict(game) + live._note_scheduled_start_candidate = lambda details: offered.append(details) + try: + live.update() + except Exception as exc: # noqa: BLE001 + print(" (update raised: %r)" % (exc,)) + return sorted(d.get("id") for d in offered if isinstance(d, dict)), live + + +def _game(gid, home, away, **extra): + game = {"id": gid, HOME: home, AWAY: away, "home_abbr": ABBR.get(home, home), + "away_abbr": ABBR.get(away, away), "is_live": False, "is_halftime": False, + "is_final": False} + game.update(extra) + return game + + +def main(): + games = [_game("fav", FAV, FAV_OPP), _game("other", OTHER_H, OTHER_A)] + + print("a board showing every live game offers every game") + ids, live = _drive(games) + check("show_all_live offers every game, upcoming ones included", + ids == ["fav", "other"]) + check("and no game was treated as live", live.live_games == []) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=True, + show_favorite_teams_only=True) + check("show_all_live still offers every game with favourites set", + ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=False) + check("favourites-only off offers every game", ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[], show_all_live=False, + show_favorite_teams_only=True) + check("favourites-only with no favourites offers every game", + ids == ["fav", "other"]) + + print("\na favourites-only board offers only its favourites' games") + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=True) + check("only the favourite's game was offered (%s)" % ids, ids == ["fav"]) + ids, _ = _drive(games, exclude_teams=[OTHER_A]) + check("an excluded team's game is never offered (%s)" % ids, ids == ["fav"]) + print("\nthe call is getattr-guarded") + live2 = _prime(_live()) + live2._note_scheduled_start_candidate = None + live2._fetch_data = lambda *a, **k: {"events": [games[0]]} + live2._extract_game_details = lambda game: dict(game) + raised = None + try: + live2.update() + except Exception as exc: # noqa: BLE001 + raised = exc + check("a core without the method does not raise (%r)" % (raised,), raised is None) + + if failures: + print("\n%d check(s) failed" % len(failures)) + return 1 + print("\nall checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/ufc-scoreboard/CHANGELOG.md b/plugins/ufc-scoreboard/CHANGELOG.md index ad3dc8d0..014b56b0 100644 --- a/plugins/ufc-scoreboard/CHANGELOG.md +++ b/plugins/ufc-scoreboard/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.20.1] - 2026-10-05 + +### Fixed +- A board showing only its favourites' live bouts no longer wakes for other + fighters' bouts. The live loop handed every bout on the scoreboard to core's + idle back-off as a start to wake for, and each one holds the poll at the + live update interval for 15 minutes, so on a busy slate the board fetched + the whole scoreboard about every 30 s for bouts it then filtered out. Only + bouts the live filter would show are offered now (football-scoreboard 3.18.7 + made the same change). Boards showing every live bout are unchanged. The + live filter moved out of `update()` into `_classify_live_game()`, as in the + other scoreboards, so the live list and the kickoff offer share one rule. + `test_kickoff_offer_follows_the_live_filter.py` covers the filter. + ## [1.20.0] - 2026-10-05 ### Changed diff --git a/plugins/ufc-scoreboard/manifest.json b/plugins/ufc-scoreboard/manifest.json index d13b2348..5f7c63b3 100644 --- a/plugins/ufc-scoreboard/manifest.json +++ b/plugins/ufc-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ufc-scoreboard", "name": "UFC Scoreboard", - "version": "1.20.0", + "version": "1.20.1", "author": "LegoGuy1000", "contributors": [ { @@ -32,6 +32,12 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "version": "1.20.1", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.1", + "notes": "A favourites-only board no longer wakes for other teams' bouts. Every bout the live fetch saw held the live poll at 30 s for 15 minutes, so on a busy slate it fetched the whole scoreboard about every 30 s for bouts it then filtered out. Only bouts the live filter would show are offered now. Boards that show every live game are unchanged." + }, { "version": "1.20.0", "released": "2026-10-05", diff --git a/plugins/ufc-scoreboard/sports.py b/plugins/ufc-scoreboard/sports.py index 1d4a1b08..105bbb0a 100644 --- a/plugins/ufc-scoreboard/sports.py +++ b/plugins/ufc-scoreboard/sports.py @@ -2694,6 +2694,20 @@ def __init__( self.game_update_timestamps = {} # {game_id: {"clock": timestamp, "score": timestamp, "last_seen": timestamp}} self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default + def _classify_live_game(self, home_abbr, away_abbr) -> bool: + """Whether a live bout should be included in the live rotation. + + Priority: show_all_live > show_favorite_teams_only disabled > no + favorites configured (fallback: show all) > favorite membership. + """ + if self.show_all_live: + return True + if not self.show_favorite_teams_only: + return True + if not self.favorite_teams: + return True + return home_abbr in self.favorite_teams or away_abbr in self.favorite_teams + def _keep_final_for_vegas(self, details: Dict) -> None: """Hold a fight that just ended, so its live Vegas card shows the result. @@ -2778,9 +2792,18 @@ def update(self): # starts, so it cannot sleep through a kickoff. # getattr-guarded: the core version floor is # advisory, so an older core must stay loadable. + # + # Only a game this board would show once it is live. + # Every kickoff holds the poll at the live cadence for a + # quarter of an hour, so offering the games the live + # filter then drops kept a favourites-only board polling + # the whole scoreboard all day (football 3.18.7). _note_start = getattr( self, "_note_scheduled_start_candidate", None) - if _note_start is not None: + if (_note_start is not None and details + and self._classify_live_game( + details.get("home_abbr"), + details.get("away_abbr"))): _note_start(details) if details: # Log game status for debugging - use INFO level to see what's happening @@ -2869,30 +2892,15 @@ def update(self): home_abbr = details.get("home_abbr") away_abbr = details.get("away_abbr") - if self.show_all_live: - # Always show all live games if show_all_live is enabled - should_include = True - include_reason = "show_all_live=True" - elif not self.show_favorite_teams_only: - # If favorite teams filtering is disabled, show all games - should_include = True - include_reason = "show_favorite_teams_only=False" - elif not self.favorite_teams: - # If favorite teams filtering is enabled but no favorites are configured, - # show all games (same behavior as SportsUpcoming) - should_include = True - include_reason = "favorite_teams is empty" - else: - # Favorite teams filtering is enabled AND favorites are configured - # Only show games involving favorite teams - home_match = home_abbr in self.favorite_teams - away_match = away_abbr in self.favorite_teams - should_include = home_match or away_match - include_reason = ( - f"favorite_teams={self.favorite_teams}, " - f"home_abbr='{home_abbr}' in_favorites={home_match}, " - f"away_abbr='{away_abbr}' in_favorites={away_match}" - ) + # See _classify_live_game for the precedence order + # (show_all_live > favourites-only); the kickoff + # offer above applies the same rule. + should_include = self._classify_live_game(home_abbr, away_abbr) + include_reason = ( + f"show_all_live={self.show_all_live}, " + f"show_favorite_teams_only={self.show_favorite_teams_only}, " + f"favorite_teams={self.favorite_teams}" + ) self.logger.debug( f"[LIVE_PRIORITY_DEBUG] {self.sport_key.upper()} filter decision for {game_str}: " diff --git a/plugins/ufc-scoreboard/test_kickoff_offer_follows_the_live_filter.py b/plugins/ufc-scoreboard/test_kickoff_offer_follows_the_live_filter.py new file mode 100644 index 00000000..27d79f60 --- /dev/null +++ b/plugins/ufc-scoreboard/test_kickoff_offer_follows_the_live_filter.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +Tests that the live loop offers the idle back-off only the kickoffs it would show. + +The core mixin clamps the idle back-off to the next known kickoff and then +holds the live cadence for a quarter of an hour after it +(_clamp_to_scheduled_start / _note_scheduled_start_candidate, tested in the +core's test_sports_shared.py). It learns the kickoffs from this loop, which +offers the games the live fetch already downloaded. What is pinned here is +which games it offers: + + * every game the board would show once it is live -- a favourites-only + board offers only its favourites' bouts (ufc's live filter has no + excluded list). Each kickoff holds the poll at live cadence for 15 + minutes, so offering games the board then filters out kept a + favourites-only board polling the whole scoreboard all day on a busy + slate (football-scoreboard 3.18.7 found it; this is the same rule); + * a board that shows every live game is unchanged: it offers every game, + upcoming ones included, even when nothing is live; + * the call is getattr-guarded, so a core without the method still loads. + +Built on the same real-SportsLive scaffolding as football-scoreboard's +test_kickoff_wakes_the_idle_poll.py. + +Run: /bin/python plugins/ufc-scoreboard/test_kickoff_offer_follows_the_live_filter.py +""" + +# A test harness: it reaches into protected members on purpose, builds a +# concrete subclass at runtime, and accepts arguments only to match the +# signatures it stands in for -- none of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +# pylint: disable=broad-exception-caught + +import os +import sys +import threading +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +_core = os.environ.get("LEDMATRIX_CORE") +if _core and (Path(_core) / "src").is_dir(): + sys.path.insert(0, _core) + +try: + import sports # noqa: E402 +except ImportError as exc: # no core checkout on the path + print("SKIP: cannot import sports.py without a LEDMatrix core (%s)" % exc) + sys.exit(2) + +SPORT_KEY = "ufc" +#: The keys a game's two sides are matched on by this plugin's live filter. +HOME, AWAY = "home_abbr", "away_abbr" +FAV, FAV_OPP, OTHER_H, OTHER_A = ("JONES", "MIOCIC", "ADESANYA", "PEREIRA") +#: The abbreviation each side shows, where it is not the key itself. +ABBR = {} + + +class _Logger: + def info(self, *a, **k): pass + def debug(self, *a, **k): pass + def warning(self, *a, **k): pass + def error(self, *a, **k): pass + def exception(self, *a, **k): pass + + +class _StubCore: + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = {} + + +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(): + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete({}, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +def _prime(live): + """The SportsCore/SportsLive state update() reads, which _StubCore skips.""" + live.is_enabled = True + live.live_games = [] + live.last_update = 0 + live.show_ranking = False + live.test_mode = False + live.no_data_interval = 300 + live.update_interval = 30 + live.live_idle_max_interval = 900 + live._empty_live_streak = 0 + live.sport_key = SPORT_KEY + live.league = SPORT_KEY + live.sport = SPORT_KEY + live._games_lock = threading.RLock() + live.favorite_teams = [] + live.exclude_teams = [] + live.show_all_live = True + live.show_favorite_teams_only = False + live.tournament_mode = False + live.show_odds = False + live._rotation_schedule = [] + live.current_game_index = 0 + live.current_game = None + live.last_game_switch = 0 + live.game_update_timestamps = {} + live.last_log_time = 0 + live.log_interval = 300 + live.stale_game_timeout = 600 + live._check_for_score = lambda *a, **k: None + return live + + +def _drive(events, **settings): + """Run one update() pass over a canned payload; return the offered ids.""" + live = _prime(_live()) + for name, value in settings.items(): + setattr(live, name, value) + offered = [] + live._fetch_data = lambda *a, **k: {"events": list(events)} + live._extract_game_details = lambda game: dict(game) + live._note_scheduled_start_candidate = lambda details: offered.append(details) + try: + live.update() + except Exception as exc: # noqa: BLE001 + print(" (update raised: %r)" % (exc,)) + return sorted(d.get("id") for d in offered if isinstance(d, dict)), live + + +def _game(gid, home, away, **extra): + game = {"id": gid, HOME: home, AWAY: away, "home_abbr": ABBR.get(home, home), + "away_abbr": ABBR.get(away, away), "is_live": False, "is_halftime": False, + "is_final": False} + game.update(extra) + return game + + +def main(): + games = [_game("fav", FAV, FAV_OPP), _game("other", OTHER_H, OTHER_A)] + + print("a board showing every live game offers every game") + ids, live = _drive(games) + check("show_all_live offers every game, upcoming ones included", + ids == ["fav", "other"]) + check("and no game was treated as live", live.live_games == []) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=True, + show_favorite_teams_only=True) + check("show_all_live still offers every game with favourites set", + ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=False) + check("favourites-only off offers every game", ids == ["fav", "other"]) + ids, _ = _drive(games, favorite_teams=[], show_all_live=False, + show_favorite_teams_only=True) + check("favourites-only with no favourites offers every game", + ids == ["fav", "other"]) + + print("\na favourites-only board offers only its favourites' games") + ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False, + show_favorite_teams_only=True) + check("only the favourite's game was offered (%s)" % ids, ids == ["fav"]) + print("\nthe call is getattr-guarded") + live2 = _prime(_live()) + live2._note_scheduled_start_candidate = None + live2._fetch_data = lambda *a, **k: {"events": [games[0]]} + live2._extract_game_details = lambda game: dict(game) + raised = None + try: + live2.update() + except Exception as exc: # noqa: BLE001 + raised = exc + check("a core without the method does not raise (%r)" % (raised,), raised is None) + + if failures: + print("\n%d check(s) failed" % len(failures)) + return 1 + print("\nall checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main())