diff --git a/plugins.json b/plugins.json index bfe64c4..c51781e 100644 --- a/plugins.json +++ b/plugins.json @@ -27,7 +27,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.0.4" + "latest_version": "1.1.1" }, { "id": "7-segment-clock", @@ -76,7 +76,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.24.4" + "latest_version": "1.25.1" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.12.1" + "latest_version": "1.13.1" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.14.2" + "latest_version": "2.15.1" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.9.2", + "latest_version": "1.10.1", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.9.2", + "latest_version": "1.10.1", "icon": "fas fa-baseball-ball" }, { @@ -760,7 +760,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.9.2" + "latest_version": "2.10.1" }, { "id": "static-image", @@ -905,7 +905,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.5", + "latest_version": "1.4.1", "icon": "fas fa-fist-raised" }, { @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.6.2", + "latest_version": "1.7.1", "last_updated": "2026-08-05" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.6.2" + "latest_version": "1.7.1" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/README.md b/plugins/afl-scoreboard/README.md index 3743e82..03c48ae 100644 --- a/plugins/afl-scoreboard/README.md +++ b/plugins/afl-scoreboard/README.md @@ -192,3 +192,42 @@ a loss. internet access and logo cache directory permissions. - **Slow updates**: Adjust `update_interval_seconds` / `live_update_interval`. - **API errors**: Check your internet connection and ESPN API availability. + +## Vegas ticker: seeing live games more often + +By default a live game **takes over** the display: the Vegas ticker stops and +this scoreboard shows full screen until the game ends. If you would rather keep +the marquee scrolling and still see scores, set this in the core config: + +```json +{ + "display": { + "vegas_scroll": { + "live_in_ticker": true, + "live_weight": 3, + "favorite_live_weight": 5 + } + } +} +``` + +The ticker is otherwise a strict round robin — every plugin appears once per +cycle — so with a dozen plugins enabled a score comes round once a lap. These +weights let this plugin claim several slots per cycle, spaced evenly through +it rather than bunched together. + +`live_weight` applies whenever this scoreboard has a live game. +`favorite_live_weight` applies when one of your `favorite_teams` is playing, so +your team's game comes round more often than other live games. That distinction +has to be made here rather than in the core, which can tell *that* a game is +live but not *whose*. + +Two things to keep in mind: + +- The weight is per **plugin**, not per game. With four games live this + scoreboard still occupies one slot at a time and picks between its own games + using `favorite_live_boost`; these weights control how often the scoreboard + itself comes round. +- More slots make the cycle **longer**, not faster — everything else appears + proportionally less often. And appearing more often only helps if the data is + fresh, which is governed by this plugin's own live update interval. diff --git a/plugins/afl-scoreboard/manager.py b/plugins/afl-scoreboard/manager.py index 588f1d3..bdfa7ad 100644 --- a/plugins/afl-scoreboard/manager.py +++ b/plugins/afl-scoreboard/manager.py @@ -727,6 +727,115 @@ def _live_manager_has_favorite_live(self, live_manager) -> bool: ) return False + # --- Vegas ticker weighting ------------------------------------------ + # + # With display.vegas_scroll.live_in_ticker set, the marquee keeps running + # through a live game and plugins can claim more than one slot per cycle. + # The core already gives any plugin with live content `live_weight`; this + # exists for the one thing the core cannot work out for itself, which is + # *whose* game is live. See the core's PLUGIN_API_REFERENCE, "Vegas scroll + # hooks", and ADVANCED_FEATURES, "Live content in the ticker". + + def get_vegas_priority_weight(self): + """Slots per Vegas cycle: more when a favorite team is playing. + + Returns None when nothing is live, which leaves the decision to the + core rather than asserting a weight of 1 -- the core may have its own + reason to boost this plugin later. + """ + try: + if not (self.has_live_priority() and self.has_live_content()): + return None + vegas = (self.global_config or {}).get('display', {}).get( + 'vegas_scroll', {}) + if self._favorite_team_is_live(): + return vegas.get('favorite_live_weight', 5) + return vegas.get('live_weight', 3) + except Exception: + # Never let a weighting question break the rotation; the core + # treats an exception as weight 1 anyway, and None says the same + # thing more cheaply. + return None + + def _favorite_team_is_live(self): + """Whether any live game or fight involves a configured favorite. + + The sports plugins do not share one data shape, so this enumerates the + real ones rather than assuming. An earlier version looked only for an + attribute holding `live_games` alongside `favorite_teams`, which was + true of five plugins and quietly false for four others -- they simply + never reported a favorite, and no test noticed because the tests used + the assumed shape rather than each plugin's own. + + Handled: + + * managers held directly on the plugin *and* inside a dict such as + ``self._managers`` (nrl, afl) + * ``live_games`` (most) and ``live_matches`` (cricket) + * ``favorite_teams`` (most) and ``favorite_fighters`` (ufc) + * identifiers ``home_abbr``/``away_abbr``, ``home_id``/``away_id``, + ``fighter1_name``/``fighter2_name``, and cricket's nested + ``teams: [{name, abbr, short_name}]`` + * ``active_celebration["game"]``, a snapshot the live manager keeps + precisely because the game leaves ``live_games`` while the + celebration is still on screen + """ + for holder in self._favorite_scan_targets(): + favorites = (getattr(holder, 'favorite_teams', None) + or getattr(holder, 'favorite_fighters', None)) + if not favorites: + continue + wanted = {str(f).strip().lower() for f in favorites if f} + if not wanted: + continue + for game in self._favorite_scan_games(holder): + if self._game_involves(game, wanted): + return True + return False + + def _favorite_scan_targets(self): + """Objects that might carry live content: attributes, and dict values. + + nrl and afl keep their per-league managers in a ``self._managers`` + dict, so walking attribute values alone finds the dict and stops. + """ + for value in list(vars(self).values()): + yield value + if isinstance(value, dict): + for nested in list(value.values()): + yield nested + + @staticmethod + def _favorite_scan_games(holder): + """Every game/fight on a holder that a favorite could be playing in.""" + for attr in ('live_games', 'live_matches'): + for game in (getattr(holder, attr, None) or []): + if isinstance(game, dict): + yield game + celebration = getattr(holder, 'active_celebration', None) + if isinstance(celebration, dict) and isinstance(celebration.get('game'), dict): + yield celebration['game'] + + @staticmethod + def _game_involves(game, wanted): + """Whether a game/fight involves one of the wanted names.""" + for field in ('home_abbr', 'away_abbr', 'home_id', 'away_id', + 'fighter1_name', 'fighter2_name'): + value = game.get(field) + if value is not None and str(value).strip().lower() in wanted: + return True + # Cricket nests its sides and matches on any of three names, by + # substring -- "india" should match "India Women". Mirrors that + # plugin's own _match_has_team rather than inventing a second rule. + for team in (game.get('teams') or []): + if not isinstance(team, dict): + continue + hay = " ".join(str(team.get(k) or '') for k in + ('name', 'abbr', 'short_name')).lower() + if any(name in hay for name in wanted): + return True + return False + def has_live_content(self) -> bool: """Whether there is live content worth showing.""" if not self.is_enabled: diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 819e70a..abbbedf 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.6.2", + "version": "1.7.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,18 @@ "afl_upcoming" ], "versions": [ + { + "version": "1.7.1", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Match favorites against this plugin's real live-data shape. The first cut looked for an attribute holding live_games beside favorite_teams, which is true of five scoreboards and quietly false for four: cricket keeps live_matches, nrl and afl hold their managers in a dict rather than as attributes, and ufc has favorite_fighters and fighter names. Those four reported every live game as non-favorite. A celebrating game is searched too, since the live manager snapshots it out of live_games while the celebration is on screen." + }, + { + "version": "1.7.0", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Ask for more turns in the Vegas ticker while a game is live, and more again when a favorite team is playing. Only takes effect with the core's display.vegas_scroll.live_in_ticker set, which keeps the marquee running instead of handing the display to a full-screen scoreboard. The core grants a live plugin live_weight on its own; this reports the favorite case, which the core cannot see -- it knows a game is live but not whose." + }, { "version": "1.6.2", "released": "2026-08-12", diff --git a/plugins/afl-scoreboard/test_vegas_priority_weight.py b/plugins/afl-scoreboard/test_vegas_priority_weight.py new file mode 100644 index 0000000..8cce241 --- /dev/null +++ b/plugins/afl-scoreboard/test_vegas_priority_weight.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Tests how often this plugin asks to appear in the Vegas ticker. + +With display.vegas_scroll.live_in_ticker set, the marquee keeps running through +a live game and a plugin can hold more than one slot per cycle. The core grants +live_weight to anything reporting live content on its own, so this hook exists +for the one thing the core cannot work out: it sees *that* a game is live, not +*whose*. + +The fixtures below use THIS plugin's real data contract -- live_games on a +manager held in a `_managers` dict, favorites in favorite_teams, and identifiers in `home_abbr`/`away_abbr`. An earlier version +of these tests used one assumed shape for all ten scoreboards, so four plugins +whose live data is shaped differently passed while never actually matching a +favorite. + +Run: /bin/python plugins/afl-scoreboard/test_vegas_priority_weight.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +import os # noqa: E402 +_core = os.environ.get('LEDMATRIX_CORE', '') +for _candidate in (_core, str(PLUGIN_DIR.parents[2] / 'LEDMatrix')): + if _candidate and (Path(_candidate) / 'src' / 'plugin_system').is_dir(): + sys.path.insert(0, _candidate) + break +else: + print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + sys.exit(2) + +import manager as m # noqa: E402 # pylint: disable=wrong-import-position + +PLUGIN_CLASS = m.AflScoreboardPlugin +GAMES_ATTR = "live_games" +FAVS_ATTR = "favorite_teams" +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +class FakeManager: + """One of this plugin's per-league live managers, in its real shape.""" + + def __init__(self, games=None, favorites=None, celebrating=None): + setattr(self, GAMES_ATTR, games or []) + setattr(self, FAVS_ATTR, favorites or []) + if celebrating is not None: + self.active_celebration = {"game": celebrating, "started_at": 0} + + +def _game(**kw): + """A live game/fight as this plugin's data source produces it.""" + return {"home_abbr": kw.get("home", "NYY"), "away_abbr": kw.get("away", "BOS")} + + +def _plugin(live_priority=True, live_content=True, managers=None, vegas=None, + nested=False): + p = PLUGIN_CLASS.__new__(PLUGIN_CLASS) + p.has_live_priority = lambda: live_priority + p.has_live_content = lambda: live_content + p.global_config = {'display': {'vegas_scroll': vegas or {}}} + managers = managers or [] + if nested: + # nrl and afl keep their managers in a dict, not as plain attributes. + p._managers = {'live_%d' % i: mgr for i, mgr in enumerate(managers)} + else: + for i, mgr in enumerate(managers): + setattr(p, 'league_%d_live' % i, mgr) + return p + + +NESTED = True +FAVORITE = 'NYY' +OTHER = 'CHC' + + +def main(): + print("nothing live means no opinion") + check("no live content -> None", + _plugin(live_content=False).get_vegas_priority_weight() is None) + check("live priority off -> None", + _plugin(live_priority=False).get_vegas_priority_weight() is None) + + print("\na live game with no favorite gets the ordinary live weight") + p = _plugin(managers=[FakeManager([_game()], [OTHER])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns live_weight", p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + print("\na favorite in the game gets the favorite weight") + p = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns favorite_live_weight", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nmatching ignores case and surrounding space") + p = _plugin(managers=[FakeManager([_game()], [" " + FAVORITE.upper() + " "])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("still matches", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nany of this plugin's managers can supply the favorite") + p = _plugin(managers=[FakeManager([], [FAVORITE]), + FakeManager([_game()], [FAVORITE])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("a later manager is still found", p.get_vegas_priority_weight() == 5) + + print("\na favorite celebrating still counts, though it has left the game list") + # The live manager snapshots the game into active_celebration precisely + # because it leaves live_games while the celebration is on screen. + p = _plugin(managers=[FakeManager([], [FAVORITE], celebrating=_game())], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("celebration snapshot is searched too", + p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nsensible defaults when the config says nothing") + check("defaults to 3 for a live game", + _plugin(managers=[FakeManager([_game()], [OTHER])], + nested=NESTED).get_vegas_priority_weight() == 3) + check("defaults to 5 for a favorite", + _plugin(managers=[FakeManager([_game()], [FAVORITE])], + nested=NESTED).get_vegas_priority_weight() == 5) + + print("\nmalformed data never breaks the rotation") + p = _plugin(managers=[FakeManager(['not-a-dict', None], [FAVORITE])], + nested=NESTED, vegas={'live_weight': 3}) + check("junk in the game list is skipped", + p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + broken = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED) + broken.has_live_content = lambda: (_ for _ in ()).throw(RuntimeError("boom")) + check("an exception yields None rather than propagating", + broken.get_vegas_priority_weight() is None) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/baseball-scoreboard/README.md b/plugins/baseball-scoreboard/README.md index 0d10e71..b32d0b7 100644 --- a/plugins/baseball-scoreboard/README.md +++ b/plugins/baseball-scoreboard/README.md @@ -458,3 +458,42 @@ a loss. - **Missing team logos**: Ensure team logo files exist in your assets/sports/ directory - **Slow updates**: Adjust the update interval in league configuration - **API errors**: Check your internet connection and ESPN API availability + +## Vegas ticker: seeing live games more often + +By default a live game **takes over** the display: the Vegas ticker stops and +this scoreboard shows full screen until the game ends. If you would rather keep +the marquee scrolling and still see scores, set this in the core config: + +```json +{ + "display": { + "vegas_scroll": { + "live_in_ticker": true, + "live_weight": 3, + "favorite_live_weight": 5 + } + } +} +``` + +The ticker is otherwise a strict round robin — every plugin appears once per +cycle — so with a dozen plugins enabled a score comes round once a lap. These +weights let this plugin claim several slots per cycle, spaced evenly through +it rather than bunched together. + +`live_weight` applies whenever this scoreboard has a live game. +`favorite_live_weight` applies when one of your `favorite_teams` is playing, so +your team's game comes round more often than other live games. That distinction +has to be made here rather than in the core, which can tell *that* a game is +live but not *whose*. + +Two things to keep in mind: + +- The weight is per **plugin**, not per game. With four games live this + scoreboard still occupies one slot at a time and picks between its own games + using `favorite_live_boost`; these weights control how often the scoreboard + itself comes round. +- More slots make the cycle **longer**, not faster — everything else appears + proportionally less often. And appearing more often only helps if the data is + fresh, which is governed by this plugin's own live update interval. diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 8859266..02f5263 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -2028,6 +2028,115 @@ def has_live_priority(self) -> bool: self.logger.debug(f"has_live_priority() called: mlb_enabled={self.mlb_enabled}, mlb_live_priority={self.mlb_live_priority}, milb_enabled={self.milb_enabled}, milb_live_priority={self.milb_live_priority}, ncaa_baseball_enabled={self.ncaa_baseball_enabled}, ncaa_baseball_live_priority={self.ncaa_baseball_live_priority}, result={result}") return result + # --- Vegas ticker weighting ------------------------------------------ + # + # With display.vegas_scroll.live_in_ticker set, the marquee keeps running + # through a live game and plugins can claim more than one slot per cycle. + # The core already gives any plugin with live content `live_weight`; this + # exists for the one thing the core cannot work out for itself, which is + # *whose* game is live. See the core's PLUGIN_API_REFERENCE, "Vegas scroll + # hooks", and ADVANCED_FEATURES, "Live content in the ticker". + + def get_vegas_priority_weight(self): + """Slots per Vegas cycle: more when a favorite team is playing. + + Returns None when nothing is live, which leaves the decision to the + core rather than asserting a weight of 1 -- the core may have its own + reason to boost this plugin later. + """ + try: + if not (self.has_live_priority() and self.has_live_content()): + return None + vegas = (self.global_config or {}).get('display', {}).get( + 'vegas_scroll', {}) + if self._favorite_team_is_live(): + return vegas.get('favorite_live_weight', 5) + return vegas.get('live_weight', 3) + except Exception: + # Never let a weighting question break the rotation; the core + # treats an exception as weight 1 anyway, and None says the same + # thing more cheaply. + return None + + def _favorite_team_is_live(self): + """Whether any live game or fight involves a configured favorite. + + The sports plugins do not share one data shape, so this enumerates the + real ones rather than assuming. An earlier version looked only for an + attribute holding `live_games` alongside `favorite_teams`, which was + true of five plugins and quietly false for four others -- they simply + never reported a favorite, and no test noticed because the tests used + the assumed shape rather than each plugin's own. + + Handled: + + * managers held directly on the plugin *and* inside a dict such as + ``self._managers`` (nrl, afl) + * ``live_games`` (most) and ``live_matches`` (cricket) + * ``favorite_teams`` (most) and ``favorite_fighters`` (ufc) + * identifiers ``home_abbr``/``away_abbr``, ``home_id``/``away_id``, + ``fighter1_name``/``fighter2_name``, and cricket's nested + ``teams: [{name, abbr, short_name}]`` + * ``active_celebration["game"]``, a snapshot the live manager keeps + precisely because the game leaves ``live_games`` while the + celebration is still on screen + """ + for holder in self._favorite_scan_targets(): + favorites = (getattr(holder, 'favorite_teams', None) + or getattr(holder, 'favorite_fighters', None)) + if not favorites: + continue + wanted = {str(f).strip().lower() for f in favorites if f} + if not wanted: + continue + for game in self._favorite_scan_games(holder): + if self._game_involves(game, wanted): + return True + return False + + def _favorite_scan_targets(self): + """Objects that might carry live content: attributes, and dict values. + + nrl and afl keep their per-league managers in a ``self._managers`` + dict, so walking attribute values alone finds the dict and stops. + """ + for value in list(vars(self).values()): + yield value + if isinstance(value, dict): + for nested in list(value.values()): + yield nested + + @staticmethod + def _favorite_scan_games(holder): + """Every game/fight on a holder that a favorite could be playing in.""" + for attr in ('live_games', 'live_matches'): + for game in (getattr(holder, attr, None) or []): + if isinstance(game, dict): + yield game + celebration = getattr(holder, 'active_celebration', None) + if isinstance(celebration, dict) and isinstance(celebration.get('game'), dict): + yield celebration['game'] + + @staticmethod + def _game_involves(game, wanted): + """Whether a game/fight involves one of the wanted names.""" + for field in ('home_abbr', 'away_abbr', 'home_id', 'away_id', + 'fighter1_name', 'fighter2_name'): + value = game.get(field) + if value is not None and str(value).strip().lower() in wanted: + return True + # Cricket nests its sides and matches on any of three names, by + # substring -- "india" should match "India Women". Mirrors that + # plugin's own _match_has_team rather than inventing a second rule. + for team in (game.get('teams') or []): + if not isinstance(team, dict): + continue + hay = " ".join(str(team.get(k) or '') for k in + ('name', 'abbr', 'short_name')).lower() + if any(name in hay for name in wanted): + return True + return False + def has_live_content(self) -> bool: if not self.is_enabled: self.logger.debug("[LIVE_PRIORITY_DEBUG] has_live_content: plugin not enabled, returning False") diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index c979b10..3b5210c 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.24.4", + "version": "1.25.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -30,6 +30,18 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.25.1", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Match favorites against this plugin's real live-data shape. The first cut looked for an attribute holding live_games beside favorite_teams, which is true of five scoreboards and quietly false for four: cricket keeps live_matches, nrl and afl hold their managers in a dict rather than as attributes, and ufc has favorite_fighters and fighter names. Those four reported every live game as non-favorite. A celebrating game is searched too, since the live manager snapshots it out of live_games while the celebration is on screen." + }, + { + "version": "1.25.0", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Ask for more turns in the Vegas ticker while a game is live, and more again when a favorite team is playing. Only takes effect with the core's display.vegas_scroll.live_in_ticker set, which keeps the marquee running instead of handing the display to a full-screen scoreboard. The core grants a live plugin live_weight on its own; this reports the favorite case, which the core cannot see -- it knows a game is live but not whose." + }, { "version": "1.24.4", "released": "2026-08-12", diff --git a/plugins/baseball-scoreboard/test_vegas_priority_weight.py b/plugins/baseball-scoreboard/test_vegas_priority_weight.py new file mode 100644 index 0000000..d6a8212 --- /dev/null +++ b/plugins/baseball-scoreboard/test_vegas_priority_weight.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Tests how often this plugin asks to appear in the Vegas ticker. + +With display.vegas_scroll.live_in_ticker set, the marquee keeps running through +a live game and a plugin can hold more than one slot per cycle. The core grants +live_weight to anything reporting live content on its own, so this hook exists +for the one thing the core cannot work out: it sees *that* a game is live, not +*whose*. + +The fixtures below use THIS plugin's real data contract -- live_games on a +manager, favorites in favorite_teams, and identifiers in `home_abbr`/`away_abbr`. An earlier version +of these tests used one assumed shape for all ten scoreboards, so four plugins +whose live data is shaped differently passed while never actually matching a +favorite. + +Run: /bin/python plugins/baseball-scoreboard/test_vegas_priority_weight.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +import os # noqa: E402 +_core = os.environ.get('LEDMATRIX_CORE', '') +for _candidate in (_core, str(PLUGIN_DIR.parents[2] / 'LEDMatrix')): + if _candidate and (Path(_candidate) / 'src' / 'plugin_system').is_dir(): + sys.path.insert(0, _candidate) + break +else: + print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + sys.exit(2) + +import manager as m # noqa: E402 # pylint: disable=wrong-import-position + +PLUGIN_CLASS = m.BaseballScoreboardPlugin +GAMES_ATTR = "live_games" +FAVS_ATTR = "favorite_teams" +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +class FakeManager: + """One of this plugin's per-league live managers, in its real shape.""" + + def __init__(self, games=None, favorites=None, celebrating=None): + setattr(self, GAMES_ATTR, games or []) + setattr(self, FAVS_ATTR, favorites or []) + if celebrating is not None: + self.active_celebration = {"game": celebrating, "started_at": 0} + + +def _game(**kw): + """A live game/fight as this plugin's data source produces it.""" + return {"home_abbr": kw.get("home", "NYY"), "away_abbr": kw.get("away", "BOS")} + + +def _plugin(live_priority=True, live_content=True, managers=None, vegas=None, + nested=False): + p = PLUGIN_CLASS.__new__(PLUGIN_CLASS) + p.has_live_priority = lambda: live_priority + p.has_live_content = lambda: live_content + p.global_config = {'display': {'vegas_scroll': vegas or {}}} + managers = managers or [] + if nested: + # nrl and afl keep their managers in a dict, not as plain attributes. + p._managers = {'live_%d' % i: mgr for i, mgr in enumerate(managers)} + else: + for i, mgr in enumerate(managers): + setattr(p, 'league_%d_live' % i, mgr) + return p + + +NESTED = False +FAVORITE = 'NYY' +OTHER = 'CHC' + + +def main(): + print("nothing live means no opinion") + check("no live content -> None", + _plugin(live_content=False).get_vegas_priority_weight() is None) + check("live priority off -> None", + _plugin(live_priority=False).get_vegas_priority_weight() is None) + + print("\na live game with no favorite gets the ordinary live weight") + p = _plugin(managers=[FakeManager([_game()], [OTHER])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns live_weight", p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + print("\na favorite in the game gets the favorite weight") + p = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns favorite_live_weight", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nmatching ignores case and surrounding space") + p = _plugin(managers=[FakeManager([_game()], [" " + FAVORITE.upper() + " "])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("still matches", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nany of this plugin's managers can supply the favorite") + p = _plugin(managers=[FakeManager([], [FAVORITE]), + FakeManager([_game()], [FAVORITE])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("a later manager is still found", p.get_vegas_priority_weight() == 5) + + print("\nsensible defaults when the config says nothing") + check("defaults to 3 for a live game", + _plugin(managers=[FakeManager([_game()], [OTHER])], + nested=NESTED).get_vegas_priority_weight() == 3) + check("defaults to 5 for a favorite", + _plugin(managers=[FakeManager([_game()], [FAVORITE])], + nested=NESTED).get_vegas_priority_weight() == 5) + + print("\nmalformed data never breaks the rotation") + p = _plugin(managers=[FakeManager(['not-a-dict', None], [FAVORITE])], + nested=NESTED, vegas={'live_weight': 3}) + check("junk in the game list is skipped", + p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + broken = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED) + broken.has_live_content = lambda: (_ for _ in ()).throw(RuntimeError("boom")) + check("an exception yields None rather than propagating", + broken.get_vegas_priority_weight() is None) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/basketball-scoreboard/README.md b/plugins/basketball-scoreboard/README.md index a973b8d..3b2e95a 100644 --- a/plugins/basketball-scoreboard/README.md +++ b/plugins/basketball-scoreboard/README.md @@ -450,3 +450,42 @@ a loss. - Adjust `game_limits.recent_games_to_show` and `game_limits.upcoming_games_to_show` - Remember: with favorite teams, these are per-team limits - Without favorite teams, these are total game limits + +## Vegas ticker: seeing live games more often + +By default a live game **takes over** the display: the Vegas ticker stops and +this scoreboard shows full screen until the game ends. If you would rather keep +the marquee scrolling and still see scores, set this in the core config: + +```json +{ + "display": { + "vegas_scroll": { + "live_in_ticker": true, + "live_weight": 3, + "favorite_live_weight": 5 + } + } +} +``` + +The ticker is otherwise a strict round robin — every plugin appears once per +cycle — so with a dozen plugins enabled a score comes round once a lap. These +weights let this plugin claim several slots per cycle, spaced evenly through +it rather than bunched together. + +`live_weight` applies whenever this scoreboard has a live game. +`favorite_live_weight` applies when one of your `favorite_teams` is playing, so +your team's game comes round more often than other live games. That distinction +has to be made here rather than in the core, which can tell *that* a game is +live but not *whose*. + +Two things to keep in mind: + +- The weight is per **plugin**, not per game. With four games live this + scoreboard still occupies one slot at a time and picks between its own games + using `favorite_live_boost`; these weights control how often the scoreboard + itself comes round. +- More slots make the cycle **longer**, not faster — everything else appears + proportionally less often. And appearing more often only helps if the data is + fresh, which is governed by this plugin's own live update interval. diff --git a/plugins/basketball-scoreboard/manager.py b/plugins/basketball-scoreboard/manager.py index 161635f..dca1394 100644 --- a/plugins/basketball-scoreboard/manager.py +++ b/plugins/basketball-scoreboard/manager.py @@ -1344,6 +1344,115 @@ def has_live_priority(self) -> bool: or (self.ncaaw_enabled and self.ncaaw_live_priority) ) + # --- Vegas ticker weighting ------------------------------------------ + # + # With display.vegas_scroll.live_in_ticker set, the marquee keeps running + # through a live game and plugins can claim more than one slot per cycle. + # The core already gives any plugin with live content `live_weight`; this + # exists for the one thing the core cannot work out for itself, which is + # *whose* game is live. See the core's PLUGIN_API_REFERENCE, "Vegas scroll + # hooks", and ADVANCED_FEATURES, "Live content in the ticker". + + def get_vegas_priority_weight(self): + """Slots per Vegas cycle: more when a favorite team is playing. + + Returns None when nothing is live, which leaves the decision to the + core rather than asserting a weight of 1 -- the core may have its own + reason to boost this plugin later. + """ + try: + if not (self.has_live_priority() and self.has_live_content()): + return None + vegas = (self.global_config or {}).get('display', {}).get( + 'vegas_scroll', {}) + if self._favorite_team_is_live(): + return vegas.get('favorite_live_weight', 5) + return vegas.get('live_weight', 3) + except Exception: + # Never let a weighting question break the rotation; the core + # treats an exception as weight 1 anyway, and None says the same + # thing more cheaply. + return None + + def _favorite_team_is_live(self): + """Whether any live game or fight involves a configured favorite. + + The sports plugins do not share one data shape, so this enumerates the + real ones rather than assuming. An earlier version looked only for an + attribute holding `live_games` alongside `favorite_teams`, which was + true of five plugins and quietly false for four others -- they simply + never reported a favorite, and no test noticed because the tests used + the assumed shape rather than each plugin's own. + + Handled: + + * managers held directly on the plugin *and* inside a dict such as + ``self._managers`` (nrl, afl) + * ``live_games`` (most) and ``live_matches`` (cricket) + * ``favorite_teams`` (most) and ``favorite_fighters`` (ufc) + * identifiers ``home_abbr``/``away_abbr``, ``home_id``/``away_id``, + ``fighter1_name``/``fighter2_name``, and cricket's nested + ``teams: [{name, abbr, short_name}]`` + * ``active_celebration["game"]``, a snapshot the live manager keeps + precisely because the game leaves ``live_games`` while the + celebration is still on screen + """ + for holder in self._favorite_scan_targets(): + favorites = (getattr(holder, 'favorite_teams', None) + or getattr(holder, 'favorite_fighters', None)) + if not favorites: + continue + wanted = {str(f).strip().lower() for f in favorites if f} + if not wanted: + continue + for game in self._favorite_scan_games(holder): + if self._game_involves(game, wanted): + return True + return False + + def _favorite_scan_targets(self): + """Objects that might carry live content: attributes, and dict values. + + nrl and afl keep their per-league managers in a ``self._managers`` + dict, so walking attribute values alone finds the dict and stops. + """ + for value in list(vars(self).values()): + yield value + if isinstance(value, dict): + for nested in list(value.values()): + yield nested + + @staticmethod + def _favorite_scan_games(holder): + """Every game/fight on a holder that a favorite could be playing in.""" + for attr in ('live_games', 'live_matches'): + for game in (getattr(holder, attr, None) or []): + if isinstance(game, dict): + yield game + celebration = getattr(holder, 'active_celebration', None) + if isinstance(celebration, dict) and isinstance(celebration.get('game'), dict): + yield celebration['game'] + + @staticmethod + def _game_involves(game, wanted): + """Whether a game/fight involves one of the wanted names.""" + for field in ('home_abbr', 'away_abbr', 'home_id', 'away_id', + 'fighter1_name', 'fighter2_name'): + value = game.get(field) + if value is not None and str(value).strip().lower() in wanted: + return True + # Cricket nests its sides and matches on any of three names, by + # substring -- "india" should match "India Women". Mirrors that + # plugin's own _match_has_team rather than inventing a second rule. + for team in (game.get('teams') or []): + if not isinstance(team, dict): + continue + hay = " ".join(str(team.get(k) or '') for k in + ('name', 'abbr', 'short_name')).lower() + if any(name in hay for name in wanted): + return True + return False + def has_live_content(self) -> bool: if not self.is_enabled: return False diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 684cccd..c717284 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.12.1", + "version": "1.13.1", "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", "category": "sports", @@ -18,6 +18,18 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.13.1", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Match favorites against this plugin's real live-data shape. The first cut looked for an attribute holding live_games beside favorite_teams, which is true of five scoreboards and quietly false for four: cricket keeps live_matches, nrl and afl hold their managers in a dict rather than as attributes, and ufc has favorite_fighters and fighter names. Those four reported every live game as non-favorite. A celebrating game is searched too, since the live manager snapshots it out of live_games while the celebration is on screen." + }, + { + "version": "1.13.0", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Ask for more turns in the Vegas ticker while a game is live, and more again when a favorite team is playing. Only takes effect with the core's display.vegas_scroll.live_in_ticker set, which keeps the marquee running instead of handing the display to a full-screen scoreboard. The core grants a live plugin live_weight on its own; this reports the favorite case, which the core cannot see -- it knows a game is live but not whose." + }, { "version": "1.12.1", "released": "2026-08-12", diff --git a/plugins/basketball-scoreboard/test_vegas_priority_weight.py b/plugins/basketball-scoreboard/test_vegas_priority_weight.py new file mode 100644 index 0000000..2ed11c1 --- /dev/null +++ b/plugins/basketball-scoreboard/test_vegas_priority_weight.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Tests how often this plugin asks to appear in the Vegas ticker. + +With display.vegas_scroll.live_in_ticker set, the marquee keeps running through +a live game and a plugin can hold more than one slot per cycle. The core grants +live_weight to anything reporting live content on its own, so this hook exists +for the one thing the core cannot work out: it sees *that* a game is live, not +*whose*. + +The fixtures below use THIS plugin's real data contract -- live_games on a +manager, favorites in favorite_teams, and identifiers in `home_abbr`/`away_abbr`. An earlier version +of these tests used one assumed shape for all ten scoreboards, so four plugins +whose live data is shaped differently passed while never actually matching a +favorite. + +Run: /bin/python plugins/basketball-scoreboard/test_vegas_priority_weight.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +import os # noqa: E402 +_core = os.environ.get('LEDMATRIX_CORE', '') +for _candidate in (_core, str(PLUGIN_DIR.parents[2] / 'LEDMatrix')): + if _candidate and (Path(_candidate) / 'src' / 'plugin_system').is_dir(): + sys.path.insert(0, _candidate) + break +else: + print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + sys.exit(2) + +import manager as m # noqa: E402 # pylint: disable=wrong-import-position + +PLUGIN_CLASS = m.BasketballScoreboardPlugin +GAMES_ATTR = "live_games" +FAVS_ATTR = "favorite_teams" +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +class FakeManager: + """One of this plugin's per-league live managers, in its real shape.""" + + def __init__(self, games=None, favorites=None, celebrating=None): + setattr(self, GAMES_ATTR, games or []) + setattr(self, FAVS_ATTR, favorites or []) + if celebrating is not None: + self.active_celebration = {"game": celebrating, "started_at": 0} + + +def _game(**kw): + """A live game/fight as this plugin's data source produces it.""" + return {"home_abbr": kw.get("home", "NYY"), "away_abbr": kw.get("away", "BOS")} + + +def _plugin(live_priority=True, live_content=True, managers=None, vegas=None, + nested=False): + p = PLUGIN_CLASS.__new__(PLUGIN_CLASS) + p.has_live_priority = lambda: live_priority + p.has_live_content = lambda: live_content + p.global_config = {'display': {'vegas_scroll': vegas or {}}} + managers = managers or [] + if nested: + # nrl and afl keep their managers in a dict, not as plain attributes. + p._managers = {'live_%d' % i: mgr for i, mgr in enumerate(managers)} + else: + for i, mgr in enumerate(managers): + setattr(p, 'league_%d_live' % i, mgr) + return p + + +NESTED = False +FAVORITE = 'NYY' +OTHER = 'CHC' + + +def main(): + print("nothing live means no opinion") + check("no live content -> None", + _plugin(live_content=False).get_vegas_priority_weight() is None) + check("live priority off -> None", + _plugin(live_priority=False).get_vegas_priority_weight() is None) + + print("\na live game with no favorite gets the ordinary live weight") + p = _plugin(managers=[FakeManager([_game()], [OTHER])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns live_weight", p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + print("\na favorite in the game gets the favorite weight") + p = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns favorite_live_weight", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nmatching ignores case and surrounding space") + p = _plugin(managers=[FakeManager([_game()], [" " + FAVORITE.upper() + " "])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("still matches", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nany of this plugin's managers can supply the favorite") + p = _plugin(managers=[FakeManager([], [FAVORITE]), + FakeManager([_game()], [FAVORITE])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("a later manager is still found", p.get_vegas_priority_weight() == 5) + + print("\nsensible defaults when the config says nothing") + check("defaults to 3 for a live game", + _plugin(managers=[FakeManager([_game()], [OTHER])], + nested=NESTED).get_vegas_priority_weight() == 3) + check("defaults to 5 for a favorite", + _plugin(managers=[FakeManager([_game()], [FAVORITE])], + nested=NESTED).get_vegas_priority_weight() == 5) + + print("\nmalformed data never breaks the rotation") + p = _plugin(managers=[FakeManager(['not-a-dict', None], [FAVORITE])], + nested=NESTED, vegas={'live_weight': 3}) + check("junk in the game list is skipped", + p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + broken = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED) + broken.has_live_content = lambda: (_ for _ in ()).throw(RuntimeError("boom")) + check("an exception yields None rather than propagating", + broken.get_vegas_priority_weight() is None) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/cricket-scoreboard/README.md b/plugins/cricket-scoreboard/README.md index 6656320..375bb0b 100644 --- a/plugins/cricket-scoreboard/README.md +++ b/plugins/cricket-scoreboard/README.md @@ -107,3 +107,42 @@ python -m unittest test_cricket_plugin # needs Pillow + requests Covers the base-6 overs conversion, run-rate math, format normalization, all four renderer branches at every matrix size, and a manager update/display smoke test driven by the mocked responses in [`test/harness.json`](test/harness.json). + +## Vegas ticker: seeing live games more often + +By default a live game **takes over** the display: the Vegas ticker stops and +this scoreboard shows full screen until the game ends. If you would rather keep +the marquee scrolling and still see scores, set this in the core config: + +```json +{ + "display": { + "vegas_scroll": { + "live_in_ticker": true, + "live_weight": 3, + "favorite_live_weight": 5 + } + } +} +``` + +The ticker is otherwise a strict round robin — every plugin appears once per +cycle — so with a dozen plugins enabled a score comes round once a lap. These +weights let this plugin claim several slots per cycle, spaced evenly through +it rather than bunched together. + +`live_weight` applies whenever this scoreboard has a live game. +`favorite_live_weight` applies when one of your `favorite_teams` is playing, so +your team's game comes round more often than other live games. That distinction +has to be made here rather than in the core, which can tell *that* a game is +live but not *whose*. + +Two things to keep in mind: + +- The weight is per **plugin**, not per game. With four games live this + scoreboard still occupies one slot at a time and picks between its own games + using `favorite_live_boost`; these weights control how often the scoreboard + itself comes round. +- More slots make the cycle **longer**, not faster — everything else appears + proportionally less often. And appearing more often only helps if the data is + fresh, which is governed by this plugin's own live update interval. diff --git a/plugins/cricket-scoreboard/manager.py b/plugins/cricket-scoreboard/manager.py index 5ceee12..6bbcfb8 100644 --- a/plugins/cricket-scoreboard/manager.py +++ b/plugins/cricket-scoreboard/manager.py @@ -384,6 +384,115 @@ def supports_dynamic_duration(self) -> bool: def has_live_priority(self) -> bool: return self.is_enabled and self.live_priority + # --- Vegas ticker weighting ------------------------------------------ + # + # With display.vegas_scroll.live_in_ticker set, the marquee keeps running + # through a live game and plugins can claim more than one slot per cycle. + # The core already gives any plugin with live content `live_weight`; this + # exists for the one thing the core cannot work out for itself, which is + # *whose* game is live. See the core's PLUGIN_API_REFERENCE, "Vegas scroll + # hooks", and ADVANCED_FEATURES, "Live content in the ticker". + + def get_vegas_priority_weight(self): + """Slots per Vegas cycle: more when a favorite team is playing. + + Returns None when nothing is live, which leaves the decision to the + core rather than asserting a weight of 1 -- the core may have its own + reason to boost this plugin later. + """ + try: + if not (self.has_live_priority() and self.has_live_content()): + return None + vegas = (self.global_config or {}).get('display', {}).get( + 'vegas_scroll', {}) + if self._favorite_team_is_live(): + return vegas.get('favorite_live_weight', 5) + return vegas.get('live_weight', 3) + except Exception: + # Never let a weighting question break the rotation; the core + # treats an exception as weight 1 anyway, and None says the same + # thing more cheaply. + return None + + def _favorite_team_is_live(self): + """Whether any live game or fight involves a configured favorite. + + The sports plugins do not share one data shape, so this enumerates the + real ones rather than assuming. An earlier version looked only for an + attribute holding `live_games` alongside `favorite_teams`, which was + true of five plugins and quietly false for four others -- they simply + never reported a favorite, and no test noticed because the tests used + the assumed shape rather than each plugin's own. + + Handled: + + * managers held directly on the plugin *and* inside a dict such as + ``self._managers`` (nrl, afl) + * ``live_games`` (most) and ``live_matches`` (cricket) + * ``favorite_teams`` (most) and ``favorite_fighters`` (ufc) + * identifiers ``home_abbr``/``away_abbr``, ``home_id``/``away_id``, + ``fighter1_name``/``fighter2_name``, and cricket's nested + ``teams: [{name, abbr, short_name}]`` + * ``active_celebration["game"]``, a snapshot the live manager keeps + precisely because the game leaves ``live_games`` while the + celebration is still on screen + """ + for holder in self._favorite_scan_targets(): + favorites = (getattr(holder, 'favorite_teams', None) + or getattr(holder, 'favorite_fighters', None)) + if not favorites: + continue + wanted = {str(f).strip().lower() for f in favorites if f} + if not wanted: + continue + for game in self._favorite_scan_games(holder): + if self._game_involves(game, wanted): + return True + return False + + def _favorite_scan_targets(self): + """Objects that might carry live content: attributes, and dict values. + + nrl and afl keep their per-league managers in a ``self._managers`` + dict, so walking attribute values alone finds the dict and stops. + """ + for value in list(vars(self).values()): + yield value + if isinstance(value, dict): + for nested in list(value.values()): + yield nested + + @staticmethod + def _favorite_scan_games(holder): + """Every game/fight on a holder that a favorite could be playing in.""" + for attr in ('live_games', 'live_matches'): + for game in (getattr(holder, attr, None) or []): + if isinstance(game, dict): + yield game + celebration = getattr(holder, 'active_celebration', None) + if isinstance(celebration, dict) and isinstance(celebration.get('game'), dict): + yield celebration['game'] + + @staticmethod + def _game_involves(game, wanted): + """Whether a game/fight involves one of the wanted names.""" + for field in ('home_abbr', 'away_abbr', 'home_id', 'away_id', + 'fighter1_name', 'fighter2_name'): + value = game.get(field) + if value is not None and str(value).strip().lower() in wanted: + return True + # Cricket nests its sides and matches on any of three names, by + # substring -- "india" should match "India Women". Mirrors that + # plugin's own _match_has_team rather than inventing a second rule. + for team in (game.get('teams') or []): + if not isinstance(team, dict): + continue + hay = " ".join(str(team.get(k) or '') for k in + ('name', 'abbr', 'short_name')).lower() + if any(name in hay for name in wanted): + return True + return False + def has_live_content(self) -> bool: if not self.is_enabled or not self.mode_enabled.get(MODE_LIVE, True): return False diff --git a/plugins/cricket-scoreboard/manifest.json b/plugins/cricket-scoreboard/manifest.json index 7ee9500..b0caaae 100644 --- a/plugins/cricket-scoreboard/manifest.json +++ b/plugins/cricket-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "cricket-scoreboard", "name": "Cricket Scoreboard", - "version": "1.0.4", + "version": "1.1.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming cricket matches across international tours (Test/ODI/T20I) and major domestic T20 leagues including the IPL, Big Bash League, The Hundred, PSL, CPL, SA20 and more. Shows runs/wickets/overs, run rates, targets, and match results.", "category": "sports", @@ -22,6 +22,18 @@ "cricket_upcoming" ], "versions": [ + { + "version": "1.1.1", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Match favorites against this plugin's real live-data shape. The first cut looked for an attribute holding live_games beside favorite_teams, which is true of five scoreboards and quietly false for four: cricket keeps live_matches, nrl and afl hold their managers in a dict rather than as attributes, and ufc has favorite_fighters and fighter names. Those four reported every live game as non-favorite. A celebrating game is searched too, since the live manager snapshots it out of live_games while the celebration is on screen." + }, + { + "version": "1.1.0", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Ask for more turns in the Vegas ticker while a game is live, and more again when a favorite team is playing. Only takes effect with the core's display.vegas_scroll.live_in_ticker set, which keeps the marquee running instead of handing the display to a full-screen scoreboard. The core grants a live plugin live_weight on its own; this reports the favorite case, which the core cannot see -- it knows a game is live but not whose." + }, { "version": "1.0.4", "released": "2026-08-12", diff --git a/plugins/cricket-scoreboard/test_vegas_priority_weight.py b/plugins/cricket-scoreboard/test_vegas_priority_weight.py new file mode 100644 index 0000000..b7ba0c9 --- /dev/null +++ b/plugins/cricket-scoreboard/test_vegas_priority_weight.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Tests how often this plugin asks to appear in the Vegas ticker. + +With display.vegas_scroll.live_in_ticker set, the marquee keeps running through +a live game and a plugin can hold more than one slot per cycle. The core grants +live_weight to anything reporting live content on its own, so this hook exists +for the one thing the core cannot work out: it sees *that* a game is live, not +*whose*. + +The fixtures below use THIS plugin's real data contract -- live_matches on a +manager, favorites in favorite_teams, and sides nested under `teams` matched by substring. An earlier version +of these tests used one assumed shape for all ten scoreboards, so four plugins +whose live data is shaped differently passed while never actually matching a +favorite. + +Run: /bin/python plugins/cricket-scoreboard/test_vegas_priority_weight.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +import os # noqa: E402 +_core = os.environ.get('LEDMATRIX_CORE', '') +for _candidate in (_core, str(PLUGIN_DIR.parents[2] / 'LEDMatrix')): + if _candidate and (Path(_candidate) / 'src' / 'plugin_system').is_dir(): + sys.path.insert(0, _candidate) + break +else: + print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + sys.exit(2) + +import manager as m # noqa: E402 # pylint: disable=wrong-import-position + +PLUGIN_CLASS = m.CricketScoreboardPlugin +GAMES_ATTR = "live_matches" +FAVS_ATTR = "favorite_teams" +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +class FakeManager: + """One of this plugin's per-league live managers, in its real shape.""" + + def __init__(self, games=None, favorites=None, celebrating=None): + setattr(self, GAMES_ATTR, games or []) + setattr(self, FAVS_ATTR, favorites or []) + if celebrating is not None: + self.active_celebration = {"game": celebrating, "started_at": 0} + + +def _game(**kw): + """A live game/fight as this plugin's data source produces it.""" + return {"teams": [{"name": kw.get("home", "India Women"), "abbr": "IND"}, + {"name": kw.get("away", "Australia"), "abbr": "AUS"}]} + + +def _plugin(live_priority=True, live_content=True, managers=None, vegas=None, + nested=False): + p = PLUGIN_CLASS.__new__(PLUGIN_CLASS) + p.has_live_priority = lambda: live_priority + p.has_live_content = lambda: live_content + p.global_config = {'display': {'vegas_scroll': vegas or {}}} + managers = managers or [] + if nested: + # nrl and afl keep their managers in a dict, not as plain attributes. + p._managers = {'live_%d' % i: mgr for i, mgr in enumerate(managers)} + else: + for i, mgr in enumerate(managers): + setattr(p, 'league_%d_live' % i, mgr) + return p + + +NESTED = False +FAVORITE = 'india' +OTHER = 'newzealand' + + +def main(): + print("nothing live means no opinion") + check("no live content -> None", + _plugin(live_content=False).get_vegas_priority_weight() is None) + check("live priority off -> None", + _plugin(live_priority=False).get_vegas_priority_weight() is None) + + print("\na live game with no favorite gets the ordinary live weight") + p = _plugin(managers=[FakeManager([_game()], [OTHER])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns live_weight", p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + print("\na favorite in the game gets the favorite weight") + p = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns favorite_live_weight", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nmatching ignores case and surrounding space") + p = _plugin(managers=[FakeManager([_game()], [" " + FAVORITE.upper() + " "])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("still matches", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nany of this plugin's managers can supply the favorite") + p = _plugin(managers=[FakeManager([], [FAVORITE]), + FakeManager([_game()], [FAVORITE])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("a later manager is still found", p.get_vegas_priority_weight() == 5) + + print("\nsensible defaults when the config says nothing") + check("defaults to 3 for a live game", + _plugin(managers=[FakeManager([_game()], [OTHER])], + nested=NESTED).get_vegas_priority_weight() == 3) + check("defaults to 5 for a favorite", + _plugin(managers=[FakeManager([_game()], [FAVORITE])], + nested=NESTED).get_vegas_priority_weight() == 5) + + print("\nmalformed data never breaks the rotation") + p = _plugin(managers=[FakeManager(['not-a-dict', None], [FAVORITE])], + nested=NESTED, vegas={'live_weight': 3}) + check("junk in the game list is skipped", + p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + broken = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED) + broken.has_live_content = lambda: (_ for _ in ()).throw(RuntimeError("boom")) + check("an exception yields None rather than propagating", + broken.get_vegas_priority_weight() is None) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/football-scoreboard/README.md b/plugins/football-scoreboard/README.md index dfd6bb0..a78bfa9 100644 --- a/plugins/football-scoreboard/README.md +++ b/plugins/football-scoreboard/README.md @@ -578,3 +578,42 @@ This plugin is built on the proven LEDMatrix core codebase. For issues or featur ## 📄 License This plugin follows the same license as the main LEDMatrix project. + +## Vegas ticker: seeing live games more often + +By default a live game **takes over** the display: the Vegas ticker stops and +this scoreboard shows full screen until the game ends. If you would rather keep +the marquee scrolling and still see scores, set this in the core config: + +```json +{ + "display": { + "vegas_scroll": { + "live_in_ticker": true, + "live_weight": 3, + "favorite_live_weight": 5 + } + } +} +``` + +The ticker is otherwise a strict round robin — every plugin appears once per +cycle — so with a dozen plugins enabled a score comes round once a lap. These +weights let this plugin claim several slots per cycle, spaced evenly through +it rather than bunched together. + +`live_weight` applies whenever this scoreboard has a live game. +`favorite_live_weight` applies when one of your `favorite_teams` is playing, so +your team's game comes round more often than other live games. That distinction +has to be made here rather than in the core, which can tell *that* a game is +live but not *whose*. + +Two things to keep in mind: + +- The weight is per **plugin**, not per game. With four games live this + scoreboard still occupies one slot at a time and picks between its own games + using `favorite_live_boost`; these weights control how often the scoreboard + itself comes round. +- More slots make the cycle **longer**, not faster — everything else appears + proportionally less often. And appearing more often only helps if the data is + fresh, which is governed by this plugin's own live update interval. diff --git a/plugins/football-scoreboard/manager.py b/plugins/football-scoreboard/manager.py index 528caa9..ea8d507 100644 --- a/plugins/football-scoreboard/manager.py +++ b/plugins/football-scoreboard/manager.py @@ -1801,6 +1801,115 @@ def has_live_priority(self) -> bool: self.logger.debug(f"has_live_priority() called: nfl_enabled={self.nfl_enabled}, nfl_live_priority={self.nfl_live_priority}, ncaa_fb_enabled={self.ncaa_fb_enabled}, ncaa_fb_live_priority={self.ncaa_fb_live_priority}, result={result}") return result + # --- Vegas ticker weighting ------------------------------------------ + # + # With display.vegas_scroll.live_in_ticker set, the marquee keeps running + # through a live game and plugins can claim more than one slot per cycle. + # The core already gives any plugin with live content `live_weight`; this + # exists for the one thing the core cannot work out for itself, which is + # *whose* game is live. See the core's PLUGIN_API_REFERENCE, "Vegas scroll + # hooks", and ADVANCED_FEATURES, "Live content in the ticker". + + def get_vegas_priority_weight(self): + """Slots per Vegas cycle: more when a favorite team is playing. + + Returns None when nothing is live, which leaves the decision to the + core rather than asserting a weight of 1 -- the core may have its own + reason to boost this plugin later. + """ + try: + if not (self.has_live_priority() and self.has_live_content()): + return None + vegas = (self.global_config or {}).get('display', {}).get( + 'vegas_scroll', {}) + if self._favorite_team_is_live(): + return vegas.get('favorite_live_weight', 5) + return vegas.get('live_weight', 3) + except Exception: + # Never let a weighting question break the rotation; the core + # treats an exception as weight 1 anyway, and None says the same + # thing more cheaply. + return None + + def _favorite_team_is_live(self): + """Whether any live game or fight involves a configured favorite. + + The sports plugins do not share one data shape, so this enumerates the + real ones rather than assuming. An earlier version looked only for an + attribute holding `live_games` alongside `favorite_teams`, which was + true of five plugins and quietly false for four others -- they simply + never reported a favorite, and no test noticed because the tests used + the assumed shape rather than each plugin's own. + + Handled: + + * managers held directly on the plugin *and* inside a dict such as + ``self._managers`` (nrl, afl) + * ``live_games`` (most) and ``live_matches`` (cricket) + * ``favorite_teams`` (most) and ``favorite_fighters`` (ufc) + * identifiers ``home_abbr``/``away_abbr``, ``home_id``/``away_id``, + ``fighter1_name``/``fighter2_name``, and cricket's nested + ``teams: [{name, abbr, short_name}]`` + * ``active_celebration["game"]``, a snapshot the live manager keeps + precisely because the game leaves ``live_games`` while the + celebration is still on screen + """ + for holder in self._favorite_scan_targets(): + favorites = (getattr(holder, 'favorite_teams', None) + or getattr(holder, 'favorite_fighters', None)) + if not favorites: + continue + wanted = {str(f).strip().lower() for f in favorites if f} + if not wanted: + continue + for game in self._favorite_scan_games(holder): + if self._game_involves(game, wanted): + return True + return False + + def _favorite_scan_targets(self): + """Objects that might carry live content: attributes, and dict values. + + nrl and afl keep their per-league managers in a ``self._managers`` + dict, so walking attribute values alone finds the dict and stops. + """ + for value in list(vars(self).values()): + yield value + if isinstance(value, dict): + for nested in list(value.values()): + yield nested + + @staticmethod + def _favorite_scan_games(holder): + """Every game/fight on a holder that a favorite could be playing in.""" + for attr in ('live_games', 'live_matches'): + for game in (getattr(holder, attr, None) or []): + if isinstance(game, dict): + yield game + celebration = getattr(holder, 'active_celebration', None) + if isinstance(celebration, dict) and isinstance(celebration.get('game'), dict): + yield celebration['game'] + + @staticmethod + def _game_involves(game, wanted): + """Whether a game/fight involves one of the wanted names.""" + for field in ('home_abbr', 'away_abbr', 'home_id', 'away_id', + 'fighter1_name', 'fighter2_name'): + value = game.get(field) + if value is not None and str(value).strip().lower() in wanted: + return True + # Cricket nests its sides and matches on any of three names, by + # substring -- "india" should match "India Women". Mirrors that + # plugin's own _match_has_team rather than inventing a second rule. + for team in (game.get('teams') or []): + if not isinstance(team, dict): + continue + hay = " ".join(str(team.get(k) or '') for k in + ('name', 'abbr', 'short_name')).lower() + if any(name in hay for name in wanted): + return True + return False + def has_live_content(self) -> bool: if not self.is_enabled: self.logger.debug("[LIVE_PRIORITY_DEBUG] has_live_content: plugin not enabled, returning False") diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index cb3e68f..e410847 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "2.14.2", + "version": "2.15.1", "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", "description": "Standalone plugin for live, recent, and upcoming football games across NFL and NCAA Football with real-time scores, down/distance, possession, and game status. Now with organized nested config!", @@ -24,6 +24,18 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "2.15.1", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Match favorites against this plugin's real live-data shape. The first cut looked for an attribute holding live_games beside favorite_teams, which is true of five scoreboards and quietly false for four: cricket keeps live_matches, nrl and afl hold their managers in a dict rather than as attributes, and ufc has favorite_fighters and fighter names. Those four reported every live game as non-favorite. A celebrating game is searched too, since the live manager snapshots it out of live_games while the celebration is on screen." + }, + { + "version": "2.15.0", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Ask for more turns in the Vegas ticker while a game is live, and more again when a favorite team is playing. Only takes effect with the core's display.vegas_scroll.live_in_ticker set, which keeps the marquee running instead of handing the display to a full-screen scoreboard. The core grants a live plugin live_weight on its own; this reports the favorite case, which the core cannot see -- it knows a game is live but not whose." + }, { "version": "2.14.2", "released": "2026-08-12", diff --git a/plugins/football-scoreboard/test_vegas_priority_weight.py b/plugins/football-scoreboard/test_vegas_priority_weight.py new file mode 100644 index 0000000..4b443db --- /dev/null +++ b/plugins/football-scoreboard/test_vegas_priority_weight.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Tests how often this plugin asks to appear in the Vegas ticker. + +With display.vegas_scroll.live_in_ticker set, the marquee keeps running through +a live game and a plugin can hold more than one slot per cycle. The core grants +live_weight to anything reporting live content on its own, so this hook exists +for the one thing the core cannot work out: it sees *that* a game is live, not +*whose*. + +The fixtures below use THIS plugin's real data contract -- live_games on a +manager, favorites in favorite_teams, and identifiers in `home_abbr`/`away_abbr`. An earlier version +of these tests used one assumed shape for all ten scoreboards, so four plugins +whose live data is shaped differently passed while never actually matching a +favorite. + +Run: /bin/python plugins/football-scoreboard/test_vegas_priority_weight.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +import os # noqa: E402 +_core = os.environ.get('LEDMATRIX_CORE', '') +for _candidate in (_core, str(PLUGIN_DIR.parents[2] / 'LEDMatrix')): + if _candidate and (Path(_candidate) / 'src' / 'plugin_system').is_dir(): + sys.path.insert(0, _candidate) + break +else: + print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + sys.exit(2) + +import manager as m # noqa: E402 # pylint: disable=wrong-import-position + +PLUGIN_CLASS = m.FootballScoreboardPlugin +GAMES_ATTR = "live_games" +FAVS_ATTR = "favorite_teams" +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +class FakeManager: + """One of this plugin's per-league live managers, in its real shape.""" + + def __init__(self, games=None, favorites=None, celebrating=None): + setattr(self, GAMES_ATTR, games or []) + setattr(self, FAVS_ATTR, favorites or []) + if celebrating is not None: + self.active_celebration = {"game": celebrating, "started_at": 0} + + +def _game(**kw): + """A live game/fight as this plugin's data source produces it.""" + return {"home_abbr": kw.get("home", "NYY"), "away_abbr": kw.get("away", "BOS")} + + +def _plugin(live_priority=True, live_content=True, managers=None, vegas=None, + nested=False): + p = PLUGIN_CLASS.__new__(PLUGIN_CLASS) + p.has_live_priority = lambda: live_priority + p.has_live_content = lambda: live_content + p.global_config = {'display': {'vegas_scroll': vegas or {}}} + managers = managers or [] + if nested: + # nrl and afl keep their managers in a dict, not as plain attributes. + p._managers = {'live_%d' % i: mgr for i, mgr in enumerate(managers)} + else: + for i, mgr in enumerate(managers): + setattr(p, 'league_%d_live' % i, mgr) + return p + + +NESTED = False +FAVORITE = 'NYY' +OTHER = 'CHC' + + +def main(): + print("nothing live means no opinion") + check("no live content -> None", + _plugin(live_content=False).get_vegas_priority_weight() is None) + check("live priority off -> None", + _plugin(live_priority=False).get_vegas_priority_weight() is None) + + print("\na live game with no favorite gets the ordinary live weight") + p = _plugin(managers=[FakeManager([_game()], [OTHER])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns live_weight", p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + print("\na favorite in the game gets the favorite weight") + p = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns favorite_live_weight", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nmatching ignores case and surrounding space") + p = _plugin(managers=[FakeManager([_game()], [" " + FAVORITE.upper() + " "])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("still matches", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nany of this plugin's managers can supply the favorite") + p = _plugin(managers=[FakeManager([], [FAVORITE]), + FakeManager([_game()], [FAVORITE])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("a later manager is still found", p.get_vegas_priority_weight() == 5) + + print("\na favorite celebrating still counts, though it has left the game list") + # The live manager snapshots the game into active_celebration precisely + # because it leaves live_games while the celebration is on screen. + p = _plugin(managers=[FakeManager([], [FAVORITE], celebrating=_game())], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("celebration snapshot is searched too", + p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nsensible defaults when the config says nothing") + check("defaults to 3 for a live game", + _plugin(managers=[FakeManager([_game()], [OTHER])], + nested=NESTED).get_vegas_priority_weight() == 3) + check("defaults to 5 for a favorite", + _plugin(managers=[FakeManager([_game()], [FAVORITE])], + nested=NESTED).get_vegas_priority_weight() == 5) + + print("\nmalformed data never breaks the rotation") + p = _plugin(managers=[FakeManager(['not-a-dict', None], [FAVORITE])], + nested=NESTED, vegas={'live_weight': 3}) + check("junk in the game list is skipped", + p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + broken = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED) + broken.has_live_content = lambda: (_ for _ in ()).throw(RuntimeError("boom")) + check("an exception yields None rather than propagating", + broken.get_vegas_priority_weight() is None) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/hockey-scoreboard/README.md b/plugins/hockey-scoreboard/README.md index 2d5653a..32e0b66 100644 --- a/plugins/hockey-scoreboard/README.md +++ b/plugins/hockey-scoreboard/README.md @@ -741,3 +741,41 @@ For the current version, author, category and tags see [`manifest.json`](manifest.json) — that's the source of truth and is what the Plugin Store reads. +## Vegas ticker: seeing live games more often + +By default a live game **takes over** the display: the Vegas ticker stops and +this scoreboard shows full screen until the game ends. If you would rather keep +the marquee scrolling and still see scores, set this in the core config: + +```json +{ + "display": { + "vegas_scroll": { + "live_in_ticker": true, + "live_weight": 3, + "favorite_live_weight": 5 + } + } +} +``` + +The ticker is otherwise a strict round robin — every plugin appears once per +cycle — so with a dozen plugins enabled a score comes round once a lap. These +weights let this plugin claim several slots per cycle, spaced evenly through +it rather than bunched together. + +`live_weight` applies whenever this scoreboard has a live game. +`favorite_live_weight` applies when one of your `favorite_teams` is playing, so +your team's game comes round more often than other live games. That distinction +has to be made here rather than in the core, which can tell *that* a game is +live but not *whose*. + +Two things to keep in mind: + +- The weight is per **plugin**, not per game. With four games live this + scoreboard still occupies one slot at a time and picks between its own games + using `favorite_live_boost`; these weights control how often the scoreboard + itself comes round. +- More slots make the cycle **longer**, not faster — everything else appears + proportionally less often. And appearing more often only helps if the data is + fresh, which is governed by this plugin's own live update interval. diff --git a/plugins/hockey-scoreboard/manager.py b/plugins/hockey-scoreboard/manager.py index 45353c8..3e21957 100644 --- a/plugins/hockey-scoreboard/manager.py +++ b/plugins/hockey-scoreboard/manager.py @@ -2049,6 +2049,115 @@ def has_live_priority(self) -> bool: ] ) + # --- Vegas ticker weighting ------------------------------------------ + # + # With display.vegas_scroll.live_in_ticker set, the marquee keeps running + # through a live game and plugins can claim more than one slot per cycle. + # The core already gives any plugin with live content `live_weight`; this + # exists for the one thing the core cannot work out for itself, which is + # *whose* game is live. See the core's PLUGIN_API_REFERENCE, "Vegas scroll + # hooks", and ADVANCED_FEATURES, "Live content in the ticker". + + def get_vegas_priority_weight(self): + """Slots per Vegas cycle: more when a favorite team is playing. + + Returns None when nothing is live, which leaves the decision to the + core rather than asserting a weight of 1 -- the core may have its own + reason to boost this plugin later. + """ + try: + if not (self.has_live_priority() and self.has_live_content()): + return None + vegas = (self.global_config or {}).get('display', {}).get( + 'vegas_scroll', {}) + if self._favorite_team_is_live(): + return vegas.get('favorite_live_weight', 5) + return vegas.get('live_weight', 3) + except Exception: + # Never let a weighting question break the rotation; the core + # treats an exception as weight 1 anyway, and None says the same + # thing more cheaply. + return None + + def _favorite_team_is_live(self): + """Whether any live game or fight involves a configured favorite. + + The sports plugins do not share one data shape, so this enumerates the + real ones rather than assuming. An earlier version looked only for an + attribute holding `live_games` alongside `favorite_teams`, which was + true of five plugins and quietly false for four others -- they simply + never reported a favorite, and no test noticed because the tests used + the assumed shape rather than each plugin's own. + + Handled: + + * managers held directly on the plugin *and* inside a dict such as + ``self._managers`` (nrl, afl) + * ``live_games`` (most) and ``live_matches`` (cricket) + * ``favorite_teams`` (most) and ``favorite_fighters`` (ufc) + * identifiers ``home_abbr``/``away_abbr``, ``home_id``/``away_id``, + ``fighter1_name``/``fighter2_name``, and cricket's nested + ``teams: [{name, abbr, short_name}]`` + * ``active_celebration["game"]``, a snapshot the live manager keeps + precisely because the game leaves ``live_games`` while the + celebration is still on screen + """ + for holder in self._favorite_scan_targets(): + favorites = (getattr(holder, 'favorite_teams', None) + or getattr(holder, 'favorite_fighters', None)) + if not favorites: + continue + wanted = {str(f).strip().lower() for f in favorites if f} + if not wanted: + continue + for game in self._favorite_scan_games(holder): + if self._game_involves(game, wanted): + return True + return False + + def _favorite_scan_targets(self): + """Objects that might carry live content: attributes, and dict values. + + nrl and afl keep their per-league managers in a ``self._managers`` + dict, so walking attribute values alone finds the dict and stops. + """ + for value in list(vars(self).values()): + yield value + if isinstance(value, dict): + for nested in list(value.values()): + yield nested + + @staticmethod + def _favorite_scan_games(holder): + """Every game/fight on a holder that a favorite could be playing in.""" + for attr in ('live_games', 'live_matches'): + for game in (getattr(holder, attr, None) or []): + if isinstance(game, dict): + yield game + celebration = getattr(holder, 'active_celebration', None) + if isinstance(celebration, dict) and isinstance(celebration.get('game'), dict): + yield celebration['game'] + + @staticmethod + def _game_involves(game, wanted): + """Whether a game/fight involves one of the wanted names.""" + for field in ('home_abbr', 'away_abbr', 'home_id', 'away_id', + 'fighter1_name', 'fighter2_name'): + value = game.get(field) + if value is not None and str(value).strip().lower() in wanted: + return True + # Cricket nests its sides and matches on any of three names, by + # substring -- "india" should match "India Women". Mirrors that + # plugin's own _match_has_team rather than inventing a second rule. + for team in (game.get('teams') or []): + if not isinstance(team, dict): + continue + hay = " ".join(str(team.get(k) or '') for k in + ('name', 'abbr', 'short_name')).lower() + if any(name in hay for name in wanted): + return True + return False + def has_live_content(self) -> bool: if not self.is_enabled: return False diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 2aea585..e09ce6e 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.9.2", + "version": "1.10.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,18 @@ } ], "versions": [ + { + "version": "1.10.1", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Match favorites against this plugin's real live-data shape. The first cut looked for an attribute holding live_games beside favorite_teams, which is true of five scoreboards and quietly false for four: cricket keeps live_matches, nrl and afl hold their managers in a dict rather than as attributes, and ufc has favorite_fighters and fighter names. Those four reported every live game as non-favorite. A celebrating game is searched too, since the live manager snapshots it out of live_games while the celebration is on screen." + }, + { + "version": "1.10.0", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Ask for more turns in the Vegas ticker while a game is live, and more again when a favorite team is playing. Only takes effect with the core's display.vegas_scroll.live_in_ticker set, which keeps the marquee running instead of handing the display to a full-screen scoreboard. The core grants a live plugin live_weight on its own; this reports the favorite case, which the core cannot see -- it knows a game is live but not whose." + }, { "version": "1.9.2", "released": "2026-08-12", diff --git a/plugins/hockey-scoreboard/test_vegas_priority_weight.py b/plugins/hockey-scoreboard/test_vegas_priority_weight.py new file mode 100644 index 0000000..bd8e2d5 --- /dev/null +++ b/plugins/hockey-scoreboard/test_vegas_priority_weight.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Tests how often this plugin asks to appear in the Vegas ticker. + +With display.vegas_scroll.live_in_ticker set, the marquee keeps running through +a live game and a plugin can hold more than one slot per cycle. The core grants +live_weight to anything reporting live content on its own, so this hook exists +for the one thing the core cannot work out: it sees *that* a game is live, not +*whose*. + +The fixtures below use THIS plugin's real data contract -- live_games on a +manager, favorites in favorite_teams, and identifiers in `home_abbr`/`away_abbr`. An earlier version +of these tests used one assumed shape for all ten scoreboards, so four plugins +whose live data is shaped differently passed while never actually matching a +favorite. + +Run: /bin/python plugins/hockey-scoreboard/test_vegas_priority_weight.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +import os # noqa: E402 +_core = os.environ.get('LEDMATRIX_CORE', '') +for _candidate in (_core, str(PLUGIN_DIR.parents[2] / 'LEDMatrix')): + if _candidate and (Path(_candidate) / 'src' / 'plugin_system').is_dir(): + sys.path.insert(0, _candidate) + break +else: + print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + sys.exit(2) + +import manager as m # noqa: E402 # pylint: disable=wrong-import-position + +PLUGIN_CLASS = m.HockeyScoreboardPlugin +GAMES_ATTR = "live_games" +FAVS_ATTR = "favorite_teams" +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +class FakeManager: + """One of this plugin's per-league live managers, in its real shape.""" + + def __init__(self, games=None, favorites=None, celebrating=None): + setattr(self, GAMES_ATTR, games or []) + setattr(self, FAVS_ATTR, favorites or []) + if celebrating is not None: + self.active_celebration = {"game": celebrating, "started_at": 0} + + +def _game(**kw): + """A live game/fight as this plugin's data source produces it.""" + return {"home_abbr": kw.get("home", "NYY"), "away_abbr": kw.get("away", "BOS")} + + +def _plugin(live_priority=True, live_content=True, managers=None, vegas=None, + nested=False): + p = PLUGIN_CLASS.__new__(PLUGIN_CLASS) + p.has_live_priority = lambda: live_priority + p.has_live_content = lambda: live_content + p.global_config = {'display': {'vegas_scroll': vegas or {}}} + managers = managers or [] + if nested: + # nrl and afl keep their managers in a dict, not as plain attributes. + p._managers = {'live_%d' % i: mgr for i, mgr in enumerate(managers)} + else: + for i, mgr in enumerate(managers): + setattr(p, 'league_%d_live' % i, mgr) + return p + + +NESTED = False +FAVORITE = 'NYY' +OTHER = 'CHC' + + +def main(): + print("nothing live means no opinion") + check("no live content -> None", + _plugin(live_content=False).get_vegas_priority_weight() is None) + check("live priority off -> None", + _plugin(live_priority=False).get_vegas_priority_weight() is None) + + print("\na live game with no favorite gets the ordinary live weight") + p = _plugin(managers=[FakeManager([_game()], [OTHER])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns live_weight", p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + print("\na favorite in the game gets the favorite weight") + p = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns favorite_live_weight", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nmatching ignores case and surrounding space") + p = _plugin(managers=[FakeManager([_game()], [" " + FAVORITE.upper() + " "])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("still matches", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nany of this plugin's managers can supply the favorite") + p = _plugin(managers=[FakeManager([], [FAVORITE]), + FakeManager([_game()], [FAVORITE])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("a later manager is still found", p.get_vegas_priority_weight() == 5) + + print("\nsensible defaults when the config says nothing") + check("defaults to 3 for a live game", + _plugin(managers=[FakeManager([_game()], [OTHER])], + nested=NESTED).get_vegas_priority_weight() == 3) + check("defaults to 5 for a favorite", + _plugin(managers=[FakeManager([_game()], [FAVORITE])], + nested=NESTED).get_vegas_priority_weight() == 5) + + print("\nmalformed data never breaks the rotation") + p = _plugin(managers=[FakeManager(['not-a-dict', None], [FAVORITE])], + nested=NESTED, vegas={'live_weight': 3}) + check("junk in the game list is skipped", + p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + broken = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED) + broken.has_live_content = lambda: (_ for _ in ()).throw(RuntimeError("boom")) + check("an exception yields None rather than propagating", + broken.get_vegas_priority_weight() is None) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/lacrosse-scoreboard/README.md b/plugins/lacrosse-scoreboard/README.md index 8c74756..168f54c 100644 --- a/plugins/lacrosse-scoreboard/README.md +++ b/plugins/lacrosse-scoreboard/README.md @@ -315,3 +315,42 @@ It stubs the LEDMatrix host modules, imports every plugin module, exercises the ## License See `LICENSE` in this directory. + +## Vegas ticker: seeing live games more often + +By default a live game **takes over** the display: the Vegas ticker stops and +this scoreboard shows full screen until the game ends. If you would rather keep +the marquee scrolling and still see scores, set this in the core config: + +```json +{ + "display": { + "vegas_scroll": { + "live_in_ticker": true, + "live_weight": 3, + "favorite_live_weight": 5 + } + } +} +``` + +The ticker is otherwise a strict round robin — every plugin appears once per +cycle — so with a dozen plugins enabled a score comes round once a lap. These +weights let this plugin claim several slots per cycle, spaced evenly through +it rather than bunched together. + +`live_weight` applies whenever this scoreboard has a live game. +`favorite_live_weight` applies when one of your `favorite_teams` is playing, so +your team's game comes round more often than other live games. That distinction +has to be made here rather than in the core, which can tell *that* a game is +live but not *whose*. + +Two things to keep in mind: + +- The weight is per **plugin**, not per game. With four games live this + scoreboard still occupies one slot at a time and picks between its own games + using `favorite_live_boost`; these weights control how often the scoreboard + itself comes round. +- More slots make the cycle **longer**, not faster — everything else appears + proportionally less often. And appearing more often only helps if the data is + fresh, which is governed by this plugin's own live update interval. diff --git a/plugins/lacrosse-scoreboard/manager.py b/plugins/lacrosse-scoreboard/manager.py index 3fa8f13..be66d89 100644 --- a/plugins/lacrosse-scoreboard/manager.py +++ b/plugins/lacrosse-scoreboard/manager.py @@ -1974,6 +1974,115 @@ def has_live_priority(self) -> bool: ] ) + # --- Vegas ticker weighting ------------------------------------------ + # + # With display.vegas_scroll.live_in_ticker set, the marquee keeps running + # through a live game and plugins can claim more than one slot per cycle. + # The core already gives any plugin with live content `live_weight`; this + # exists for the one thing the core cannot work out for itself, which is + # *whose* game is live. See the core's PLUGIN_API_REFERENCE, "Vegas scroll + # hooks", and ADVANCED_FEATURES, "Live content in the ticker". + + def get_vegas_priority_weight(self): + """Slots per Vegas cycle: more when a favorite team is playing. + + Returns None when nothing is live, which leaves the decision to the + core rather than asserting a weight of 1 -- the core may have its own + reason to boost this plugin later. + """ + try: + if not (self.has_live_priority() and self.has_live_content()): + return None + vegas = (self.global_config or {}).get('display', {}).get( + 'vegas_scroll', {}) + if self._favorite_team_is_live(): + return vegas.get('favorite_live_weight', 5) + return vegas.get('live_weight', 3) + except Exception: + # Never let a weighting question break the rotation; the core + # treats an exception as weight 1 anyway, and None says the same + # thing more cheaply. + return None + + def _favorite_team_is_live(self): + """Whether any live game or fight involves a configured favorite. + + The sports plugins do not share one data shape, so this enumerates the + real ones rather than assuming. An earlier version looked only for an + attribute holding `live_games` alongside `favorite_teams`, which was + true of five plugins and quietly false for four others -- they simply + never reported a favorite, and no test noticed because the tests used + the assumed shape rather than each plugin's own. + + Handled: + + * managers held directly on the plugin *and* inside a dict such as + ``self._managers`` (nrl, afl) + * ``live_games`` (most) and ``live_matches`` (cricket) + * ``favorite_teams`` (most) and ``favorite_fighters`` (ufc) + * identifiers ``home_abbr``/``away_abbr``, ``home_id``/``away_id``, + ``fighter1_name``/``fighter2_name``, and cricket's nested + ``teams: [{name, abbr, short_name}]`` + * ``active_celebration["game"]``, a snapshot the live manager keeps + precisely because the game leaves ``live_games`` while the + celebration is still on screen + """ + for holder in self._favorite_scan_targets(): + favorites = (getattr(holder, 'favorite_teams', None) + or getattr(holder, 'favorite_fighters', None)) + if not favorites: + continue + wanted = {str(f).strip().lower() for f in favorites if f} + if not wanted: + continue + for game in self._favorite_scan_games(holder): + if self._game_involves(game, wanted): + return True + return False + + def _favorite_scan_targets(self): + """Objects that might carry live content: attributes, and dict values. + + nrl and afl keep their per-league managers in a ``self._managers`` + dict, so walking attribute values alone finds the dict and stops. + """ + for value in list(vars(self).values()): + yield value + if isinstance(value, dict): + for nested in list(value.values()): + yield nested + + @staticmethod + def _favorite_scan_games(holder): + """Every game/fight on a holder that a favorite could be playing in.""" + for attr in ('live_games', 'live_matches'): + for game in (getattr(holder, attr, None) or []): + if isinstance(game, dict): + yield game + celebration = getattr(holder, 'active_celebration', None) + if isinstance(celebration, dict) and isinstance(celebration.get('game'), dict): + yield celebration['game'] + + @staticmethod + def _game_involves(game, wanted): + """Whether a game/fight involves one of the wanted names.""" + for field in ('home_abbr', 'away_abbr', 'home_id', 'away_id', + 'fighter1_name', 'fighter2_name'): + value = game.get(field) + if value is not None and str(value).strip().lower() in wanted: + return True + # Cricket nests its sides and matches on any of three names, by + # substring -- "india" should match "India Women". Mirrors that + # plugin's own _match_has_team rather than inventing a second rule. + for team in (game.get('teams') or []): + if not isinstance(team, dict): + continue + hay = " ".join(str(team.get(k) or '') for k in + ('name', 'abbr', 'short_name')).lower() + if any(name in hay for name in wanted): + return True + return False + def has_live_content(self) -> bool: if not self.is_enabled: return False diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index aec473d..d29b058 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.9.2", + "version": "1.10.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,18 @@ } ], "versions": [ + { + "version": "1.10.1", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Match favorites against this plugin's real live-data shape. The first cut looked for an attribute holding live_games beside favorite_teams, which is true of five scoreboards and quietly false for four: cricket keeps live_matches, nrl and afl hold their managers in a dict rather than as attributes, and ufc has favorite_fighters and fighter names. Those four reported every live game as non-favorite. A celebrating game is searched too, since the live manager snapshots it out of live_games while the celebration is on screen." + }, + { + "version": "1.10.0", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Ask for more turns in the Vegas ticker while a game is live, and more again when a favorite team is playing. Only takes effect with the core's display.vegas_scroll.live_in_ticker set, which keeps the marquee running instead of handing the display to a full-screen scoreboard. The core grants a live plugin live_weight on its own; this reports the favorite case, which the core cannot see -- it knows a game is live but not whose." + }, { "version": "1.9.2", "released": "2026-08-12", diff --git a/plugins/lacrosse-scoreboard/test_vegas_priority_weight.py b/plugins/lacrosse-scoreboard/test_vegas_priority_weight.py new file mode 100644 index 0000000..8eedabf --- /dev/null +++ b/plugins/lacrosse-scoreboard/test_vegas_priority_weight.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Tests how often this plugin asks to appear in the Vegas ticker. + +With display.vegas_scroll.live_in_ticker set, the marquee keeps running through +a live game and a plugin can hold more than one slot per cycle. The core grants +live_weight to anything reporting live content on its own, so this hook exists +for the one thing the core cannot work out: it sees *that* a game is live, not +*whose*. + +The fixtures below use THIS plugin's real data contract -- live_games on a +manager, favorites in favorite_teams, and identifiers in `home_abbr`/`away_abbr`. An earlier version +of these tests used one assumed shape for all ten scoreboards, so four plugins +whose live data is shaped differently passed while never actually matching a +favorite. + +Run: /bin/python plugins/lacrosse-scoreboard/test_vegas_priority_weight.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +import os # noqa: E402 +_core = os.environ.get('LEDMATRIX_CORE', '') +for _candidate in (_core, str(PLUGIN_DIR.parents[2] / 'LEDMatrix')): + if _candidate and (Path(_candidate) / 'src' / 'plugin_system').is_dir(): + sys.path.insert(0, _candidate) + break +else: + print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + sys.exit(2) + +import manager as m # noqa: E402 # pylint: disable=wrong-import-position + +PLUGIN_CLASS = m.LacrosseScoreboardPlugin +GAMES_ATTR = "live_games" +FAVS_ATTR = "favorite_teams" +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +class FakeManager: + """One of this plugin's per-league live managers, in its real shape.""" + + def __init__(self, games=None, favorites=None, celebrating=None): + setattr(self, GAMES_ATTR, games or []) + setattr(self, FAVS_ATTR, favorites or []) + if celebrating is not None: + self.active_celebration = {"game": celebrating, "started_at": 0} + + +def _game(**kw): + """A live game/fight as this plugin's data source produces it.""" + return {"home_abbr": kw.get("home", "NYY"), "away_abbr": kw.get("away", "BOS")} + + +def _plugin(live_priority=True, live_content=True, managers=None, vegas=None, + nested=False): + p = PLUGIN_CLASS.__new__(PLUGIN_CLASS) + p.has_live_priority = lambda: live_priority + p.has_live_content = lambda: live_content + p.global_config = {'display': {'vegas_scroll': vegas or {}}} + managers = managers or [] + if nested: + # nrl and afl keep their managers in a dict, not as plain attributes. + p._managers = {'live_%d' % i: mgr for i, mgr in enumerate(managers)} + else: + for i, mgr in enumerate(managers): + setattr(p, 'league_%d_live' % i, mgr) + return p + + +NESTED = False +FAVORITE = 'NYY' +OTHER = 'CHC' + + +def main(): + print("nothing live means no opinion") + check("no live content -> None", + _plugin(live_content=False).get_vegas_priority_weight() is None) + check("live priority off -> None", + _plugin(live_priority=False).get_vegas_priority_weight() is None) + + print("\na live game with no favorite gets the ordinary live weight") + p = _plugin(managers=[FakeManager([_game()], [OTHER])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns live_weight", p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + print("\na favorite in the game gets the favorite weight") + p = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns favorite_live_weight", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nmatching ignores case and surrounding space") + p = _plugin(managers=[FakeManager([_game()], [" " + FAVORITE.upper() + " "])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("still matches", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nany of this plugin's managers can supply the favorite") + p = _plugin(managers=[FakeManager([], [FAVORITE]), + FakeManager([_game()], [FAVORITE])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("a later manager is still found", p.get_vegas_priority_weight() == 5) + + print("\nsensible defaults when the config says nothing") + check("defaults to 3 for a live game", + _plugin(managers=[FakeManager([_game()], [OTHER])], + nested=NESTED).get_vegas_priority_weight() == 3) + check("defaults to 5 for a favorite", + _plugin(managers=[FakeManager([_game()], [FAVORITE])], + nested=NESTED).get_vegas_priority_weight() == 5) + + print("\nmalformed data never breaks the rotation") + p = _plugin(managers=[FakeManager(['not-a-dict', None], [FAVORITE])], + nested=NESTED, vegas={'live_weight': 3}) + check("junk in the game list is skipped", + p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + broken = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED) + broken.has_live_content = lambda: (_ for _ in ()).throw(RuntimeError("boom")) + check("an exception yields None rather than propagating", + broken.get_vegas_priority_weight() is None) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/nrl-scoreboard/README.md b/plugins/nrl-scoreboard/README.md index e1e9077..b932fda 100644 --- a/plugins/nrl-scoreboard/README.md +++ b/plugins/nrl-scoreboard/README.md @@ -142,3 +142,42 @@ a loss. ## License See `LICENSE`. + +## Vegas ticker: seeing live games more often + +By default a live game **takes over** the display: the Vegas ticker stops and +this scoreboard shows full screen until the game ends. If you would rather keep +the marquee scrolling and still see scores, set this in the core config: + +```json +{ + "display": { + "vegas_scroll": { + "live_in_ticker": true, + "live_weight": 3, + "favorite_live_weight": 5 + } + } +} +``` + +The ticker is otherwise a strict round robin — every plugin appears once per +cycle — so with a dozen plugins enabled a score comes round once a lap. These +weights let this plugin claim several slots per cycle, spaced evenly through +it rather than bunched together. + +`live_weight` applies whenever this scoreboard has a live game. +`favorite_live_weight` applies when one of your `favorite_teams` is playing, so +your team's game comes round more often than other live games. That distinction +has to be made here rather than in the core, which can tell *that* a game is +live but not *whose*. + +Two things to keep in mind: + +- The weight is per **plugin**, not per game. With four games live this + scoreboard still occupies one slot at a time and picks between its own games + using `favorite_live_boost`; these weights control how often the scoreboard + itself comes round. +- More slots make the cycle **longer**, not faster — everything else appears + proportionally less often. And appearing more often only helps if the data is + fresh, which is governed by this plugin's own live update interval. diff --git a/plugins/nrl-scoreboard/manager.py b/plugins/nrl-scoreboard/manager.py index 51d7214..ceb889b 100644 --- a/plugins/nrl-scoreboard/manager.py +++ b/plugins/nrl-scoreboard/manager.py @@ -728,6 +728,115 @@ def _has_favorite_or_all_live(self, live_manager) -> bool: ) return False + # --- Vegas ticker weighting ------------------------------------------ + # + # With display.vegas_scroll.live_in_ticker set, the marquee keeps running + # through a live game and plugins can claim more than one slot per cycle. + # The core already gives any plugin with live content `live_weight`; this + # exists for the one thing the core cannot work out for itself, which is + # *whose* game is live. See the core's PLUGIN_API_REFERENCE, "Vegas scroll + # hooks", and ADVANCED_FEATURES, "Live content in the ticker". + + def get_vegas_priority_weight(self): + """Slots per Vegas cycle: more when a favorite team is playing. + + Returns None when nothing is live, which leaves the decision to the + core rather than asserting a weight of 1 -- the core may have its own + reason to boost this plugin later. + """ + try: + if not (self.has_live_priority() and self.has_live_content()): + return None + vegas = (self.global_config or {}).get('display', {}).get( + 'vegas_scroll', {}) + if self._favorite_team_is_live(): + return vegas.get('favorite_live_weight', 5) + return vegas.get('live_weight', 3) + except Exception: + # Never let a weighting question break the rotation; the core + # treats an exception as weight 1 anyway, and None says the same + # thing more cheaply. + return None + + def _favorite_team_is_live(self): + """Whether any live game or fight involves a configured favorite. + + The sports plugins do not share one data shape, so this enumerates the + real ones rather than assuming. An earlier version looked only for an + attribute holding `live_games` alongside `favorite_teams`, which was + true of five plugins and quietly false for four others -- they simply + never reported a favorite, and no test noticed because the tests used + the assumed shape rather than each plugin's own. + + Handled: + + * managers held directly on the plugin *and* inside a dict such as + ``self._managers`` (nrl, afl) + * ``live_games`` (most) and ``live_matches`` (cricket) + * ``favorite_teams`` (most) and ``favorite_fighters`` (ufc) + * identifiers ``home_abbr``/``away_abbr``, ``home_id``/``away_id``, + ``fighter1_name``/``fighter2_name``, and cricket's nested + ``teams: [{name, abbr, short_name}]`` + * ``active_celebration["game"]``, a snapshot the live manager keeps + precisely because the game leaves ``live_games`` while the + celebration is still on screen + """ + for holder in self._favorite_scan_targets(): + favorites = (getattr(holder, 'favorite_teams', None) + or getattr(holder, 'favorite_fighters', None)) + if not favorites: + continue + wanted = {str(f).strip().lower() for f in favorites if f} + if not wanted: + continue + for game in self._favorite_scan_games(holder): + if self._game_involves(game, wanted): + return True + return False + + def _favorite_scan_targets(self): + """Objects that might carry live content: attributes, and dict values. + + nrl and afl keep their per-league managers in a ``self._managers`` + dict, so walking attribute values alone finds the dict and stops. + """ + for value in list(vars(self).values()): + yield value + if isinstance(value, dict): + for nested in list(value.values()): + yield nested + + @staticmethod + def _favorite_scan_games(holder): + """Every game/fight on a holder that a favorite could be playing in.""" + for attr in ('live_games', 'live_matches'): + for game in (getattr(holder, attr, None) or []): + if isinstance(game, dict): + yield game + celebration = getattr(holder, 'active_celebration', None) + if isinstance(celebration, dict) and isinstance(celebration.get('game'), dict): + yield celebration['game'] + + @staticmethod + def _game_involves(game, wanted): + """Whether a game/fight involves one of the wanted names.""" + for field in ('home_abbr', 'away_abbr', 'home_id', 'away_id', + 'fighter1_name', 'fighter2_name'): + value = game.get(field) + if value is not None and str(value).strip().lower() in wanted: + return True + # Cricket nests its sides and matches on any of three names, by + # substring -- "india" should match "India Women". Mirrors that + # plugin's own _match_has_team rather than inventing a second rule. + for team in (game.get('teams') or []): + if not isinstance(team, dict): + continue + hay = " ".join(str(team.get(k) or '') for k in + ('name', 'abbr', 'short_name')).lower() + if any(name in hay for name in wanted): + return True + return False + def has_live_content(self) -> bool: """Check if there is live content worth showing.""" if not self.is_enabled: diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 2857f63..8a46203 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.6.2", + "version": "1.7.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,18 @@ "nrl_upcoming" ], "versions": [ + { + "version": "1.7.1", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Match favorites against this plugin's real live-data shape. The first cut looked for an attribute holding live_games beside favorite_teams, which is true of five scoreboards and quietly false for four: cricket keeps live_matches, nrl and afl hold their managers in a dict rather than as attributes, and ufc has favorite_fighters and fighter names. Those four reported every live game as non-favorite. A celebrating game is searched too, since the live manager snapshots it out of live_games while the celebration is on screen." + }, + { + "version": "1.7.0", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Ask for more turns in the Vegas ticker while a game is live, and more again when a favorite team is playing. Only takes effect with the core's display.vegas_scroll.live_in_ticker set, which keeps the marquee running instead of handing the display to a full-screen scoreboard. The core grants a live plugin live_weight on its own; this reports the favorite case, which the core cannot see -- it knows a game is live but not whose." + }, { "version": "1.6.2", "released": "2026-08-12", diff --git a/plugins/nrl-scoreboard/test_vegas_priority_weight.py b/plugins/nrl-scoreboard/test_vegas_priority_weight.py new file mode 100644 index 0000000..f4698fd --- /dev/null +++ b/plugins/nrl-scoreboard/test_vegas_priority_weight.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Tests how often this plugin asks to appear in the Vegas ticker. + +With display.vegas_scroll.live_in_ticker set, the marquee keeps running through +a live game and a plugin can hold more than one slot per cycle. The core grants +live_weight to anything reporting live content on its own, so this hook exists +for the one thing the core cannot work out: it sees *that* a game is live, not +*whose*. + +The fixtures below use THIS plugin's real data contract -- live_games on a +manager held in a `_managers` dict, favorites in favorite_teams, and identifiers in `home_abbr`/`away_abbr`. An earlier version +of these tests used one assumed shape for all ten scoreboards, so four plugins +whose live data is shaped differently passed while never actually matching a +favorite. + +Run: /bin/python plugins/nrl-scoreboard/test_vegas_priority_weight.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +import os # noqa: E402 +_core = os.environ.get('LEDMATRIX_CORE', '') +for _candidate in (_core, str(PLUGIN_DIR.parents[2] / 'LEDMatrix')): + if _candidate and (Path(_candidate) / 'src' / 'plugin_system').is_dir(): + sys.path.insert(0, _candidate) + break +else: + print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + sys.exit(2) + +import manager as m # noqa: E402 # pylint: disable=wrong-import-position + +PLUGIN_CLASS = m.NrlScoreboardPlugin +GAMES_ATTR = "live_games" +FAVS_ATTR = "favorite_teams" +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +class FakeManager: + """One of this plugin's per-league live managers, in its real shape.""" + + def __init__(self, games=None, favorites=None, celebrating=None): + setattr(self, GAMES_ATTR, games or []) + setattr(self, FAVS_ATTR, favorites or []) + if celebrating is not None: + self.active_celebration = {"game": celebrating, "started_at": 0} + + +def _game(**kw): + """A live game/fight as this plugin's data source produces it.""" + return {"home_abbr": kw.get("home", "NYY"), "away_abbr": kw.get("away", "BOS")} + + +def _plugin(live_priority=True, live_content=True, managers=None, vegas=None, + nested=False): + p = PLUGIN_CLASS.__new__(PLUGIN_CLASS) + p.has_live_priority = lambda: live_priority + p.has_live_content = lambda: live_content + p.global_config = {'display': {'vegas_scroll': vegas or {}}} + managers = managers or [] + if nested: + # nrl and afl keep their managers in a dict, not as plain attributes. + p._managers = {'live_%d' % i: mgr for i, mgr in enumerate(managers)} + else: + for i, mgr in enumerate(managers): + setattr(p, 'league_%d_live' % i, mgr) + return p + + +NESTED = True +FAVORITE = 'NYY' +OTHER = 'CHC' + + +def main(): + print("nothing live means no opinion") + check("no live content -> None", + _plugin(live_content=False).get_vegas_priority_weight() is None) + check("live priority off -> None", + _plugin(live_priority=False).get_vegas_priority_weight() is None) + + print("\na live game with no favorite gets the ordinary live weight") + p = _plugin(managers=[FakeManager([_game()], [OTHER])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns live_weight", p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + print("\na favorite in the game gets the favorite weight") + p = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns favorite_live_weight", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nmatching ignores case and surrounding space") + p = _plugin(managers=[FakeManager([_game()], [" " + FAVORITE.upper() + " "])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("still matches", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nany of this plugin's managers can supply the favorite") + p = _plugin(managers=[FakeManager([], [FAVORITE]), + FakeManager([_game()], [FAVORITE])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("a later manager is still found", p.get_vegas_priority_weight() == 5) + + print("\na favorite celebrating still counts, though it has left the game list") + # The live manager snapshots the game into active_celebration precisely + # because it leaves live_games while the celebration is on screen. + p = _plugin(managers=[FakeManager([], [FAVORITE], celebrating=_game())], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("celebration snapshot is searched too", + p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nsensible defaults when the config says nothing") + check("defaults to 3 for a live game", + _plugin(managers=[FakeManager([_game()], [OTHER])], + nested=NESTED).get_vegas_priority_weight() == 3) + check("defaults to 5 for a favorite", + _plugin(managers=[FakeManager([_game()], [FAVORITE])], + nested=NESTED).get_vegas_priority_weight() == 5) + + print("\nmalformed data never breaks the rotation") + p = _plugin(managers=[FakeManager(['not-a-dict', None], [FAVORITE])], + nested=NESTED, vegas={'live_weight': 3}) + check("junk in the game list is skipped", + p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + broken = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED) + broken.has_live_content = lambda: (_ for _ in ()).throw(RuntimeError("boom")) + check("an exception yields None rather than propagating", + broken.get_vegas_priority_weight() is None) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/soccer-scoreboard/README.md b/plugins/soccer-scoreboard/README.md index 5a9c37c..0f7a260 100644 --- a/plugins/soccer-scoreboard/README.md +++ b/plugins/soccer-scoreboard/README.md @@ -365,3 +365,42 @@ a loss. ## Advanced Configuration For more advanced users, you can add additional leagues by modifying the `ESPN_API_URLS` dictionary in the plugin code and updating the configuration schema accordingly. + +## Vegas ticker: seeing live games more often + +By default a live game **takes over** the display: the Vegas ticker stops and +this scoreboard shows full screen until the game ends. If you would rather keep +the marquee scrolling and still see scores, set this in the core config: + +```json +{ + "display": { + "vegas_scroll": { + "live_in_ticker": true, + "live_weight": 3, + "favorite_live_weight": 5 + } + } +} +``` + +The ticker is otherwise a strict round robin — every plugin appears once per +cycle — so with a dozen plugins enabled a score comes round once a lap. These +weights let this plugin claim several slots per cycle, spaced evenly through +it rather than bunched together. + +`live_weight` applies whenever this scoreboard has a live game. +`favorite_live_weight` applies when one of your `favorite_teams` is playing, so +your team's game comes round more often than other live games. That distinction +has to be made here rather than in the core, which can tell *that* a game is +live but not *whose*. + +Two things to keep in mind: + +- The weight is per **plugin**, not per game. With four games live this + scoreboard still occupies one slot at a time and picks between its own games + using `favorite_live_boost`; these weights control how often the scoreboard + itself comes round. +- More slots make the cycle **longer**, not faster — everything else appears + proportionally less often. And appearing more often only helps if the data is + fresh, which is governed by this plugin's own live update interval. diff --git a/plugins/soccer-scoreboard/manager.py b/plugins/soccer-scoreboard/manager.py index 9b69f77..f853a48 100644 --- a/plugins/soccer-scoreboard/manager.py +++ b/plugins/soccer-scoreboard/manager.py @@ -2040,6 +2040,115 @@ def _get_active_celebration_manager(self): return league_key, live_manager return None + # --- Vegas ticker weighting ------------------------------------------ + # + # With display.vegas_scroll.live_in_ticker set, the marquee keeps running + # through a live game and plugins can claim more than one slot per cycle. + # The core already gives any plugin with live content `live_weight`; this + # exists for the one thing the core cannot work out for itself, which is + # *whose* game is live. See the core's PLUGIN_API_REFERENCE, "Vegas scroll + # hooks", and ADVANCED_FEATURES, "Live content in the ticker". + + def get_vegas_priority_weight(self): + """Slots per Vegas cycle: more when a favorite team is playing. + + Returns None when nothing is live, which leaves the decision to the + core rather than asserting a weight of 1 -- the core may have its own + reason to boost this plugin later. + """ + try: + if not (self.has_live_priority() and self.has_live_content()): + return None + vegas = (self.global_config or {}).get('display', {}).get( + 'vegas_scroll', {}) + if self._favorite_team_is_live(): + return vegas.get('favorite_live_weight', 5) + return vegas.get('live_weight', 3) + except Exception: + # Never let a weighting question break the rotation; the core + # treats an exception as weight 1 anyway, and None says the same + # thing more cheaply. + return None + + def _favorite_team_is_live(self): + """Whether any live game or fight involves a configured favorite. + + The sports plugins do not share one data shape, so this enumerates the + real ones rather than assuming. An earlier version looked only for an + attribute holding `live_games` alongside `favorite_teams`, which was + true of five plugins and quietly false for four others -- they simply + never reported a favorite, and no test noticed because the tests used + the assumed shape rather than each plugin's own. + + Handled: + + * managers held directly on the plugin *and* inside a dict such as + ``self._managers`` (nrl, afl) + * ``live_games`` (most) and ``live_matches`` (cricket) + * ``favorite_teams`` (most) and ``favorite_fighters`` (ufc) + * identifiers ``home_abbr``/``away_abbr``, ``home_id``/``away_id``, + ``fighter1_name``/``fighter2_name``, and cricket's nested + ``teams: [{name, abbr, short_name}]`` + * ``active_celebration["game"]``, a snapshot the live manager keeps + precisely because the game leaves ``live_games`` while the + celebration is still on screen + """ + for holder in self._favorite_scan_targets(): + favorites = (getattr(holder, 'favorite_teams', None) + or getattr(holder, 'favorite_fighters', None)) + if not favorites: + continue + wanted = {str(f).strip().lower() for f in favorites if f} + if not wanted: + continue + for game in self._favorite_scan_games(holder): + if self._game_involves(game, wanted): + return True + return False + + def _favorite_scan_targets(self): + """Objects that might carry live content: attributes, and dict values. + + nrl and afl keep their per-league managers in a ``self._managers`` + dict, so walking attribute values alone finds the dict and stops. + """ + for value in list(vars(self).values()): + yield value + if isinstance(value, dict): + for nested in list(value.values()): + yield nested + + @staticmethod + def _favorite_scan_games(holder): + """Every game/fight on a holder that a favorite could be playing in.""" + for attr in ('live_games', 'live_matches'): + for game in (getattr(holder, attr, None) or []): + if isinstance(game, dict): + yield game + celebration = getattr(holder, 'active_celebration', None) + if isinstance(celebration, dict) and isinstance(celebration.get('game'), dict): + yield celebration['game'] + + @staticmethod + def _game_involves(game, wanted): + """Whether a game/fight involves one of the wanted names.""" + for field in ('home_abbr', 'away_abbr', 'home_id', 'away_id', + 'fighter1_name', 'fighter2_name'): + value = game.get(field) + if value is not None and str(value).strip().lower() in wanted: + return True + # Cricket nests its sides and matches on any of three names, by + # substring -- "india" should match "India Women". Mirrors that + # plugin's own _match_has_team rather than inventing a second rule. + for team in (game.get('teams') or []): + if not isinstance(team, dict): + continue + hay = " ".join(str(team.get(k) or '') for k in + ('name', 'abbr', 'short_name')).lower() + if any(name in hay for name in wanted): + return True + return False + def has_live_content(self) -> bool: """Check if any league has live content.""" if not self.is_enabled: diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index b7b6f0e..ec55d7d 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.9.2", + "version": "2.10.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", @@ -26,6 +26,18 @@ "soccer_upcoming" ], "versions": [ + { + "version": "2.10.1", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Match favorites against this plugin's real live-data shape. The first cut looked for an attribute holding live_games beside favorite_teams, which is true of five scoreboards and quietly false for four: cricket keeps live_matches, nrl and afl hold their managers in a dict rather than as attributes, and ufc has favorite_fighters and fighter names. Those four reported every live game as non-favorite. A celebrating game is searched too, since the live manager snapshots it out of live_games while the celebration is on screen." + }, + { + "version": "2.10.0", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Ask for more turns in the Vegas ticker while a game is live, and more again when a favorite team is playing. Only takes effect with the core's display.vegas_scroll.live_in_ticker set, which keeps the marquee running instead of handing the display to a full-screen scoreboard. The core grants a live plugin live_weight on its own; this reports the favorite case, which the core cannot see -- it knows a game is live but not whose." + }, { "version": "2.9.2", "released": "2026-08-12", diff --git a/plugins/soccer-scoreboard/test_vegas_priority_weight.py b/plugins/soccer-scoreboard/test_vegas_priority_weight.py new file mode 100644 index 0000000..fe1282a --- /dev/null +++ b/plugins/soccer-scoreboard/test_vegas_priority_weight.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Tests how often this plugin asks to appear in the Vegas ticker. + +With display.vegas_scroll.live_in_ticker set, the marquee keeps running through +a live game and a plugin can hold more than one slot per cycle. The core grants +live_weight to anything reporting live content on its own, so this hook exists +for the one thing the core cannot work out: it sees *that* a game is live, not +*whose*. + +The fixtures below use THIS plugin's real data contract -- live_games on a +manager, favorites in favorite_teams, and identifiers in `home_abbr`/`away_abbr`. An earlier version +of these tests used one assumed shape for all ten scoreboards, so four plugins +whose live data is shaped differently passed while never actually matching a +favorite. + +Run: /bin/python plugins/soccer-scoreboard/test_vegas_priority_weight.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +import os # noqa: E402 +_core = os.environ.get('LEDMATRIX_CORE', '') +for _candidate in (_core, str(PLUGIN_DIR.parents[2] / 'LEDMatrix')): + if _candidate and (Path(_candidate) / 'src' / 'plugin_system').is_dir(): + sys.path.insert(0, _candidate) + break +else: + print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + sys.exit(2) + +import manager as m # noqa: E402 # pylint: disable=wrong-import-position + +PLUGIN_CLASS = m.SoccerScoreboardPlugin +GAMES_ATTR = "live_games" +FAVS_ATTR = "favorite_teams" +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +class FakeManager: + """One of this plugin's per-league live managers, in its real shape.""" + + def __init__(self, games=None, favorites=None, celebrating=None): + setattr(self, GAMES_ATTR, games or []) + setattr(self, FAVS_ATTR, favorites or []) + if celebrating is not None: + self.active_celebration = {"game": celebrating, "started_at": 0} + + +def _game(**kw): + """A live game/fight as this plugin's data source produces it.""" + return {"home_abbr": kw.get("home", "NYY"), "away_abbr": kw.get("away", "BOS")} + + +def _plugin(live_priority=True, live_content=True, managers=None, vegas=None, + nested=False): + p = PLUGIN_CLASS.__new__(PLUGIN_CLASS) + p.has_live_priority = lambda: live_priority + p.has_live_content = lambda: live_content + p.global_config = {'display': {'vegas_scroll': vegas or {}}} + managers = managers or [] + if nested: + # nrl and afl keep their managers in a dict, not as plain attributes. + p._managers = {'live_%d' % i: mgr for i, mgr in enumerate(managers)} + else: + for i, mgr in enumerate(managers): + setattr(p, 'league_%d_live' % i, mgr) + return p + + +NESTED = False +FAVORITE = 'NYY' +OTHER = 'CHC' + + +def main(): + print("nothing live means no opinion") + check("no live content -> None", + _plugin(live_content=False).get_vegas_priority_weight() is None) + check("live priority off -> None", + _plugin(live_priority=False).get_vegas_priority_weight() is None) + + print("\na live game with no favorite gets the ordinary live weight") + p = _plugin(managers=[FakeManager([_game()], [OTHER])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns live_weight", p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + print("\na favorite in the game gets the favorite weight") + p = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns favorite_live_weight", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nmatching ignores case and surrounding space") + p = _plugin(managers=[FakeManager([_game()], [" " + FAVORITE.upper() + " "])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("still matches", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nany of this plugin's managers can supply the favorite") + p = _plugin(managers=[FakeManager([], [FAVORITE]), + FakeManager([_game()], [FAVORITE])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("a later manager is still found", p.get_vegas_priority_weight() == 5) + + print("\nsensible defaults when the config says nothing") + check("defaults to 3 for a live game", + _plugin(managers=[FakeManager([_game()], [OTHER])], + nested=NESTED).get_vegas_priority_weight() == 3) + check("defaults to 5 for a favorite", + _plugin(managers=[FakeManager([_game()], [FAVORITE])], + nested=NESTED).get_vegas_priority_weight() == 5) + + print("\nmalformed data never breaks the rotation") + p = _plugin(managers=[FakeManager(['not-a-dict', None], [FAVORITE])], + nested=NESTED, vegas={'live_weight': 3}) + check("junk in the game list is skipped", + p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + broken = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED) + broken.has_live_content = lambda: (_ for _ in ()).throw(RuntimeError("boom")) + check("an exception yields None rather than propagating", + broken.get_vegas_priority_weight() is None) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/ufc-scoreboard/README.md b/plugins/ufc-scoreboard/README.md index dc9ed65..1b57e78 100644 --- a/plugins/ufc-scoreboard/README.md +++ b/plugins/ufc-scoreboard/README.md @@ -83,3 +83,41 @@ ESPN's public MMA endpoints. No API key required. Be mindful of ## License GPL-3.0, same as the LEDMatrix project. + +## Vegas ticker: seeing live games more often + +By default a live game **takes over** the display: the Vegas ticker stops and +this scoreboard shows full screen until the game ends. If you would rather keep +the marquee scrolling and still see scores, set this in the core config: + +```json +{ + "display": { + "vegas_scroll": { + "live_in_ticker": true, + "live_weight": 3, + "favorite_live_weight": 5 + } + } +} +``` + +The ticker is otherwise a strict round robin — every plugin appears once per +cycle — so with a dozen plugins enabled a score comes round once a lap. These +weights let this plugin claim several slots per cycle, spaced evenly through +it rather than bunched together. + +`live_weight` applies whenever this scoreboard has a live game. +`favorite_live_weight` applies when one of your `ufc.favorite_fighters` is in a +live fight, so your fighter's bout comes round more often than other live fights. That distinction +has to be made here rather than in the core, which can tell *that* a game is +live but not *whose*. + +Two things to keep in mind: + +- The weight is per **plugin**, not per game. With four fights live this + scoreboard still occupies one slot at a time and picks between its own fights; these weights control how often the scoreboard + itself comes round. +- More slots make the cycle **longer**, not faster — everything else appears + proportionally less often. And appearing more often only helps if the data is + fresh, which is governed by this plugin's own live update interval. diff --git a/plugins/ufc-scoreboard/manager.py b/plugins/ufc-scoreboard/manager.py index d953b4f..4f875b7 100644 --- a/plugins/ufc-scoreboard/manager.py +++ b/plugins/ufc-scoreboard/manager.py @@ -682,6 +682,115 @@ def has_live_priority(self) -> bool: return False return self.ufc_enabled and self.ufc_live_priority + # --- Vegas ticker weighting ------------------------------------------ + # + # With display.vegas_scroll.live_in_ticker set, the marquee keeps running + # through a live game and plugins can claim more than one slot per cycle. + # The core already gives any plugin with live content `live_weight`; this + # exists for the one thing the core cannot work out for itself, which is + # *whose* game is live. See the core's PLUGIN_API_REFERENCE, "Vegas scroll + # hooks", and ADVANCED_FEATURES, "Live content in the ticker". + + def get_vegas_priority_weight(self): + """Slots per Vegas cycle: more when a favorite team is playing. + + Returns None when nothing is live, which leaves the decision to the + core rather than asserting a weight of 1 -- the core may have its own + reason to boost this plugin later. + """ + try: + if not (self.has_live_priority() and self.has_live_content()): + return None + vegas = (self.global_config or {}).get('display', {}).get( + 'vegas_scroll', {}) + if self._favorite_team_is_live(): + return vegas.get('favorite_live_weight', 5) + return vegas.get('live_weight', 3) + except Exception: + # Never let a weighting question break the rotation; the core + # treats an exception as weight 1 anyway, and None says the same + # thing more cheaply. + return None + + def _favorite_team_is_live(self): + """Whether any live game or fight involves a configured favorite. + + The sports plugins do not share one data shape, so this enumerates the + real ones rather than assuming. An earlier version looked only for an + attribute holding `live_games` alongside `favorite_teams`, which was + true of five plugins and quietly false for four others -- they simply + never reported a favorite, and no test noticed because the tests used + the assumed shape rather than each plugin's own. + + Handled: + + * managers held directly on the plugin *and* inside a dict such as + ``self._managers`` (nrl, afl) + * ``live_games`` (most) and ``live_matches`` (cricket) + * ``favorite_teams`` (most) and ``favorite_fighters`` (ufc) + * identifiers ``home_abbr``/``away_abbr``, ``home_id``/``away_id``, + ``fighter1_name``/``fighter2_name``, and cricket's nested + ``teams: [{name, abbr, short_name}]`` + * ``active_celebration["game"]``, a snapshot the live manager keeps + precisely because the game leaves ``live_games`` while the + celebration is still on screen + """ + for holder in self._favorite_scan_targets(): + favorites = (getattr(holder, 'favorite_teams', None) + or getattr(holder, 'favorite_fighters', None)) + if not favorites: + continue + wanted = {str(f).strip().lower() for f in favorites if f} + if not wanted: + continue + for game in self._favorite_scan_games(holder): + if self._game_involves(game, wanted): + return True + return False + + def _favorite_scan_targets(self): + """Objects that might carry live content: attributes, and dict values. + + nrl and afl keep their per-league managers in a ``self._managers`` + dict, so walking attribute values alone finds the dict and stops. + """ + for value in list(vars(self).values()): + yield value + if isinstance(value, dict): + for nested in list(value.values()): + yield nested + + @staticmethod + def _favorite_scan_games(holder): + """Every game/fight on a holder that a favorite could be playing in.""" + for attr in ('live_games', 'live_matches'): + for game in (getattr(holder, attr, None) or []): + if isinstance(game, dict): + yield game + celebration = getattr(holder, 'active_celebration', None) + if isinstance(celebration, dict) and isinstance(celebration.get('game'), dict): + yield celebration['game'] + + @staticmethod + def _game_involves(game, wanted): + """Whether a game/fight involves one of the wanted names.""" + for field in ('home_abbr', 'away_abbr', 'home_id', 'away_id', + 'fighter1_name', 'fighter2_name'): + value = game.get(field) + if value is not None and str(value).strip().lower() in wanted: + return True + # Cricket nests its sides and matches on any of three names, by + # substring -- "india" should match "India Women". Mirrors that + # plugin's own _match_has_team rather than inventing a second rule. + for team in (game.get('teams') or []): + if not isinstance(team, dict): + continue + hay = " ".join(str(team.get(k) or '') for k in + ('name', 'abbr', 'short_name')).lower() + if any(name in hay for name in wanted): + return True + return False + def has_live_content(self) -> bool: if not self.is_enabled: return False diff --git a/plugins/ufc-scoreboard/manifest.json b/plugins/ufc-scoreboard/manifest.json index 1a4fe90..951df3d 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.3.5", + "version": "1.4.1", "author": "LegoGuy1000", "contributors": [ { @@ -32,6 +32,18 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "version": "1.4.1", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Match favorites against this plugin's real live-data shape. The first cut looked for an attribute holding live_games beside favorite_teams, which is true of five scoreboards and quietly false for four: cricket keeps live_matches, nrl and afl hold their managers in a dict rather than as attributes, and ufc has favorite_fighters and fighter names. Those four reported every live game as non-favorite. A celebrating game is searched too, since the live manager snapshots it out of live_games while the celebration is on screen." + }, + { + "version": "1.4.0", + "released": "2026-08-12", + "ledmatrix_min_version": "2.0.0", + "notes": "Ask for more turns in the Vegas ticker while a game is live, and more again when a favorite fighter is in a live fight. Only takes effect with the core's display.vegas_scroll.live_in_ticker set, which keeps the marquee running instead of handing the display to a full-screen scoreboard. The core grants a live plugin live_weight on its own; this reports the favorite case, which the core cannot see -- it knows a game is live but not whose." + }, { "version": "1.3.5", "released": "2026-08-12", diff --git a/plugins/ufc-scoreboard/test_vegas_priority_weight.py b/plugins/ufc-scoreboard/test_vegas_priority_weight.py new file mode 100644 index 0000000..6f666a7 --- /dev/null +++ b/plugins/ufc-scoreboard/test_vegas_priority_weight.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Tests how often this plugin asks to appear in the Vegas ticker. + +With display.vegas_scroll.live_in_ticker set, the marquee keeps running through +a live game and a plugin can hold more than one slot per cycle. The core grants +live_weight to anything reporting live content on its own, so this hook exists +for the one thing the core cannot work out: it sees *that* a game is live, not +*whose*. + +The fixtures below use THIS plugin's real data contract -- live_games on a +manager, favorites in favorite_fighters, and identifiers in `fighter1_name`/`fighter2_name`. An earlier version +of these tests used one assumed shape for all ten scoreboards, so four plugins +whose live data is shaped differently passed while never actually matching a +favorite. + +Run: /bin/python plugins/ufc-scoreboard/test_vegas_priority_weight.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +import os # noqa: E402 +_core = os.environ.get('LEDMATRIX_CORE', '') +for _candidate in (_core, str(PLUGIN_DIR.parents[2] / 'LEDMatrix')): + if _candidate and (Path(_candidate) / 'src' / 'plugin_system').is_dir(): + sys.path.insert(0, _candidate) + break +else: + print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + sys.exit(2) + +import manager as m # noqa: E402 # pylint: disable=wrong-import-position + +PLUGIN_CLASS = m.UFCScoreboardPlugin +GAMES_ATTR = "live_games" +FAVS_ATTR = "favorite_fighters" +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +class FakeManager: + """One of this plugin's per-league live managers, in its real shape.""" + + def __init__(self, games=None, favorites=None, celebrating=None): + setattr(self, GAMES_ATTR, games or []) + setattr(self, FAVS_ATTR, favorites or []) + if celebrating is not None: + self.active_celebration = {"game": celebrating, "started_at": 0} + + +def _game(**kw): + """A live game/fight as this plugin's data source produces it.""" + return {"fighter1_name": kw.get("home", "Jon Jones"), "fighter2_name": kw.get("away", "Stipe Miocic")} + + +def _plugin(live_priority=True, live_content=True, managers=None, vegas=None, + nested=False): + p = PLUGIN_CLASS.__new__(PLUGIN_CLASS) + p.has_live_priority = lambda: live_priority + p.has_live_content = lambda: live_content + p.global_config = {'display': {'vegas_scroll': vegas or {}}} + managers = managers or [] + if nested: + # nrl and afl keep their managers in a dict, not as plain attributes. + p._managers = {'live_%d' % i: mgr for i, mgr in enumerate(managers)} + else: + for i, mgr in enumerate(managers): + setattr(p, 'league_%d_live' % i, mgr) + return p + + +NESTED = False +FAVORITE = 'Jon Jones' +OTHER = 'Someone Else' + + +def main(): + print("nothing live means no opinion") + check("no live content -> None", + _plugin(live_content=False).get_vegas_priority_weight() is None) + check("live priority off -> None", + _plugin(live_priority=False).get_vegas_priority_weight() is None) + + print("\na live game with no favorite gets the ordinary live weight") + p = _plugin(managers=[FakeManager([_game()], [OTHER])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns live_weight", p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + print("\na favorite in the game gets the favorite weight") + p = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED, + vegas={'live_weight': 3, 'favorite_live_weight': 5}) + check("returns favorite_live_weight", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nmatching ignores case and surrounding space") + p = _plugin(managers=[FakeManager([_game()], [" " + FAVORITE.upper() + " "])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("still matches", p.get_vegas_priority_weight() == 5, + "got %r" % p.get_vegas_priority_weight()) + + print("\nany of this plugin's managers can supply the favorite") + p = _plugin(managers=[FakeManager([], [FAVORITE]), + FakeManager([_game()], [FAVORITE])], + nested=NESTED, vegas={'favorite_live_weight': 5}) + check("a later manager is still found", p.get_vegas_priority_weight() == 5) + + print("\nsensible defaults when the config says nothing") + check("defaults to 3 for a live game", + _plugin(managers=[FakeManager([_game()], [OTHER])], + nested=NESTED).get_vegas_priority_weight() == 3) + check("defaults to 5 for a favorite", + _plugin(managers=[FakeManager([_game()], [FAVORITE])], + nested=NESTED).get_vegas_priority_weight() == 5) + + print("\nmalformed data never breaks the rotation") + p = _plugin(managers=[FakeManager(['not-a-dict', None], [FAVORITE])], + nested=NESTED, vegas={'live_weight': 3}) + check("junk in the game list is skipped", + p.get_vegas_priority_weight() == 3, + "got %r" % p.get_vegas_priority_weight()) + + broken = _plugin(managers=[FakeManager([_game()], [FAVORITE])], nested=NESTED) + broken.has_live_content = lambda: (_ for _ in ()).throw(RuntimeError("boom")) + check("an exception yields None rather than propagating", + broken.get_vegas_priority_weight() is None) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main())