From f76a89033f8f00aca28c62f172891931e7622c20 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 16:17:12 -0400 Subject: [PATCH] feat(scoreboards): inherit the shared sports.py logic from the core Forty-five method bodies in each of these eight sports.py files were byte-identical to the same forty-five in every other scoreboard: 1,007 lines per plugin, 8,056 duplicated in total. They now come from src.common.sports_shared (ChuckBuilds/LEDMatrix#515) and each class inherits the matching mixin. 8,321 lines removed. Three of the forty-eight identical bodies stayed behind on purpose, because a byte-identical body is not automatically safe to move: - _get_timezone binds resolve_timezone from a per-plugin module (hockey_timezone, soccer_timezone, ...). All eight of those files differ -- each carries its own _WRITEBACK_FIXED_IN -- so hoisting the caller would have silently bound every scoreboard to one plugin's copy. - _extract_game_details and _fetch_data are @abstractmethod stubs. They are the sport contract; satisfying them from a mixin would let a plugin instantiate without implementing its own sport. Class constants are left in place rather than removed, so each plugin's own values still shadow the mixin's defaults. That matters for afl and basketball, which set _SCORE_PROBE_TEXT to "000-000"; the mixin's "00-00" is a default for future plugins, not a change to these. Test changes, all of them making tests exercise the real module rather than a stub: - Nineteen tests stubbed "src"/"src.common" as plain ModuleTypes, so the new import failed with "'src.common' is not a package". They now give those stubs a __path__ into the core, which lets genuine submodules resolve while the stubbed ones stay stubbed. Stubbing the mixins instead would have made every one of those tests pass against dummies -- which is how B5 shipped four of eight broken with every gate green. - soccer/test_schedule_horizon.py AST-parses sports.py looking for _get_weeks_data, which now lives in the core. It looks in both places; its assertions are about the body, which moved verbatim. Verification: all 176 safety-harness renders byte-identical to pristine main, 245 plugin tests pass, five repo gates pass. The one remaining fleet failure is 7-segment-clock/test_render_polarity.py, which fails identically on main and is fixed separately in #364. Versions are bumped above the sports_card/geometry branch rather than above main, since that branch lands first and already claims the next minor. --- plugins.json | 16 +- plugins/afl-scoreboard/manifest.json | 11 +- plugins/afl-scoreboard/sports.py | 1048 +--------------- plugins/afl-scoreboard/test_afl_plugin.py | 18 + plugins/baseball-scoreboard/manifest.json | 17 +- plugins/baseball-scoreboard/sports.py | 1051 +--------------- plugins/basketball-scoreboard/manifest.json | 13 +- plugins/basketball-scoreboard/sports.py | 1050 +--------------- .../test_favorite_live_boost.py | 18 + .../test_non_favorite_live_duration.py | 18 + plugins/football-scoreboard/manifest.json | 13 +- plugins/football-scoreboard/sports.py | 1047 +--------------- .../test_adaptive_layout_mode.py | 17 + .../test_score_celebration.py | 17 + plugins/hockey-scoreboard/manifest.json | 11 +- plugins/hockey-scoreboard/sports.py | 1050 +--------------- .../test_favorite_live_boost.py | 18 + .../test_non_favorite_live_duration.py | 18 + plugins/lacrosse-scoreboard/manifest.json | 13 +- plugins/lacrosse-scoreboard/sports.py | 1050 +--------------- .../test_favorite_live_boost.py | 18 + .../test_lacrosse_plugin.py | 18 + .../test_non_favorite_live_duration.py | 18 + plugins/nrl-scoreboard/manifest.json | 11 +- plugins/nrl-scoreboard/sports.py | 1050 +--------------- plugins/soccer-scoreboard/manifest.json | 15 +- plugins/soccer-scoreboard/sports.py | 1056 +---------------- .../test/test_empty_mode_no_blank.py | 16 + .../test_custom_league_config.py | 20 +- .../test_goal_celebration.py | 17 + .../test_league_registry_atomic_swap.py | 18 + .../test_live_mode_targeting.py | 18 + .../test_non_favorite_live_duration.py | 18 + .../test_schedule_horizon.py | 35 +- .../soccer-scoreboard/test_world_cup_flags.py | 18 + 35 files changed, 462 insertions(+), 8398 deletions(-) diff --git a/plugins.json b/plugins.json index 610c1736..d8b3ab54 100644 --- a/plugins.json +++ b/plugins.json @@ -76,7 +76,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.37.1" + "latest_version": "1.39.0" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.26.1" + "latest_version": "1.28.0" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "3.1.1" + "latest_version": "3.3.0" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.22.1", + "latest_version": "1.24.0", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.21.1", + "latest_version": "1.23.0", "icon": "fas fa-baseball-ball" }, { @@ -760,7 +760,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "2.21.1" + "latest_version": "2.23.0" }, { "id": "static-image", @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.19.2", + "latest_version": "1.21.0", "last_updated": "2026-09-02" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.18.1" + "latest_version": "1.20.0" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index d341ebac..9e4b5228 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.19.2", + "version": "1.21.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,13 @@ "afl_upcoming" ], "versions": [ + { + "version": "1.21.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "The sports.py logic every scoreboard shares moves to the core. Forty-five method bodies here were byte-identical to the same forty-five in every other scoreboard -- the selection and rotation engine, the font, colour and date subsystem, and the switch-mode upcoming card -- so they now come from src.common.sports_shared and this plugin inherits them. 1038 lines removed here. A fix to any of that now reaches every scoreboard at once instead of needing eight identical edits. Three deliberately stayed behind: _get_timezone, because it binds a per-plugin timezone module whose contents differ, and the two abstract stubs that define what makes this sport its own. Nothing drawn changes -- the bodies moved rather than being rewritten, and all 176 safety-harness renders across the eight plugins are byte-identical to before. The floor rises to 3.3.0, the release that first ships the shared module.", + "changelog": "Documentation only, no behaviour change. Rewrites the README around real rendered screenshots of the 2026 finals series and documents every configuration option. Writes down the game-selection logic in full: the three selection paths, and that upcoming_games_to_show/recent_games_to_show mean a per-team budget under show_favorite_teams_only but a total otherwise. Records four verified dead ends so they stop costing people time: show_odds is a no-op for AFL because ESPN publishes no odds block for the league (though it still issues one odds request per selected game), show_ranking has no poll to draw, dynamic_duration.min_duration_seconds is never read, and background_service.max_workers is ignored in favour of a single worker. Also corrects README defaults that had drifted from the schema (show_favorite_teams_only, display_duration, show_odds)." + }, { "version": "1.19.2", "released": "2026-09-02", @@ -296,6 +303,6 @@ "class_name": "AflScoreboardPlugin", "config_schema": "config_schema.json", "compatible_versions": [ - ">=3.2.0" + ">=3.3.0" ] } diff --git a/plugins/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py index b15bf2dd..d58b48f5 100644 --- a/plugins/afl-scoreboard/sports.py +++ b/plugins/afl-scoreboard/sports.py @@ -40,6 +40,8 @@ if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) from src.logo_downloader import LogoDownloader, download_missing_logo +from src.common.sports_shared import ( + SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin) def _resolve_font_path(path: str) -> str: @@ -85,7 +87,6 @@ def _resolve_font_path(path: str) -> str: return path - _DEFAULT_LOOKBACK_DAYS = 14 _DEFAULT_LOOKAHEAD_DAYS = 7 _MIN_WINDOW_DAYS = 1 @@ -154,7 +155,7 @@ def _logo_needs_refresh(logo_file) -> bool: return False -class SportsCore(ABC): +class SportsCore(SportsCoreSharedMixin, ABC): def __init__( self, config: Dict[str, Any], @@ -493,24 +494,6 @@ def _get_logo_directory_fallbacks(self, configured_dir: Path) -> List[Path]: return unique_fallbacks - def _get_season_schedule_dates(self) -> tuple[str, str]: - return "", "" - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Placeholder draw method - subclasses should override.""" - # This base method will be simple, subclasses provide specifics - try: - img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - status = game.get("status_text", "N/A") - self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) - self.display_manager.image.paste(img, (0, 0)) - # Don't call update_display here, let subclasses handle it after drawing - except Exception as e: - self.logger.error( - f"Error in base _draw_scorebug_layout: {e}", exc_info=True - ) - def display(self, force_clear: bool = False) -> bool: """Render the current game. Returns False when nothing can be shown.""" if not self.is_enabled: # Check if module is enabled @@ -558,62 +541,6 @@ def display(self, force_clear: bool = False) -> bool: 'four_by_six': '4x6-font.ttf', } - @classmethod - def _crisp_size(cls, font_file, desired): - """Snap *desired* to the nearest size *font_file* renders crisply at. - - A face with no known grid is returned unchanged, so a user-supplied - font is never second-guessed. - """ - font_file = cls._FONT_NAME_ALIASES.get(font_file, font_file) - grid = cls._FONT_PIXEL_GRID.get(font_file) - if not grid or not desired or desired <= 0: - return desired - return max(grid, int(round(float(desired) / grid)) * grid) - - def _schema_font_size(self, element_key): - """The font_size this plugin's config_schema.json declares, or None.""" - if not element_key: - return None - cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None) - if cache is None: - cache = {} - try: - import json - schema_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'config_schema.json') - with open(schema_path) as fh: - schema = json.load(fh) - props = (schema.get('properties', {}) - .get('customization', {}) - .get('properties', {})) - for key, spec in props.items(): - size = spec.get('properties', {}).get('font_size', {}).get('default') - if size is not None: - cache[key] = int(size) - except Exception: - cache = {} - self.__class__._SCHEMA_FONT_SIZES = cache - return cache.get(element_key) - - def _resolve_font_size(self, element_config, element_key, default_size, font_name): - """Size to render at: the user's choice, or a grid-snapped default. - - A configured size counts as a real choice only when it differs from - the schema default. The web UI writes the whole schema default block - on every save, so "font_size == schema default" carries no intent and - would otherwise pin every install to an anti-aliased size forever. - """ - configured = (element_config or {}).get('font_size') - if configured is not None: - try: - configured = int(configured) - if configured != self._schema_font_size(element_key): - return configured - except (TypeError, ValueError): - pass - return self._crisp_size(font_name, default_size) - def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], default_size: int = 8, element_key=None, default_font: Optional[str] = None) -> ImageFont.FreeTypeFont: """ Load a custom font from an element configuration dictionary. @@ -707,124 +634,6 @@ def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", ) - def _card_option(self, key: str, default: Any = None) -> Any: - """Read one key from the scroll_card config block.""" - block = (self.config or {}).get("scroll_card") - if isinstance(block, dict) and block.get(key) is not None: - return block.get(key) - return default - - def _switch_upcoming_center(self) -> str: - """Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'.""" - mode = str(self._card_option("switch_upcoming_center", "date_time") - or "date_time").lower() - if mode == "inherit": - mode = str(self._card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time", "none") else "date_time" - - def _vs_text(self) -> str: - """Separator drawn between the teams -- "VS", "@", "at", anything.""" - return str(self._card_option("vs_text", "VS")) - - def _switch_date_format(self) -> str: - """Date style for the full-screen scorebug. - - Its own key rather than the shared ``date_format`` because the two - displays disagree about the default: the scroll card renders "Sep 19" - while _extract_game_details_common emits "9/19", the "numeric" style, - and this scorebug has always drawn it. Reading the shared key here - would restyle every existing panel on update -- and "leave it alone - when unset" is not available, because the core merges schema defaults - into the config on every load, so the key is never actually unset. - "inherit" opts into the scroll and Vegas setting. - """ - fmt = str(self._card_option("switch_date_format", "numeric") or "numeric").lower() - if fmt == "inherit": - fmt = str(self._card_option("date_format", "abbrev") or "abbrev").lower() - return fmt - - def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: - """Format an upcoming date per scroll_card.switch_date_format.""" - raw = str(date_text or "").strip() - if not raw: - return raw - fmt = self._switch_date_format() - if fmt == "numeric": - return raw - parts = raw.replace("-", "/").split("/") - if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): - return raw - month, day = int(parts[0]), int(parts[1]) - if not 1 <= month <= 12: - return raw - name = self._MONTH_ABBR[month - 1] - if fmt == "numeric_day_first": - return f"{day}/{month}" - if fmt == "day_first": - return f"{day} {name}" - if fmt == "weekday": - weekday = self._weekday_for(game) - return f"{weekday} {name} {day}" if weekday else f"{name} {day}" - return f"{name} {day}" - - def _weekday_for(self, game: Optional[Dict]) -> str: - """Weekday abbreviation from the game's start time, or ''.""" - if not game: - return "" - raw = game.get("start_time_utc") or game.get("start_time") - if not raw: - return "" - try: - start = raw if isinstance(raw, datetime) else datetime.fromisoformat( - str(raw).replace("Z", "+00:00")) - return self._WEEKDAY_ABBR[start.astimezone(self._get_timezone()).weekday()] - except (ValueError, TypeError, OverflowError): - return "" - - def _format_game_time(self, time_text: str) -> str: - """Return the time as-is (12h) or converted to 24h.""" - raw = str(time_text or "").strip() - if not raw or str(self._card_option("time_format", "12h")) != "24h": - return raw - cleaned = raw.upper().replace(" ", "") - meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" - if not meridiem: - return raw - try: - hh, _, mm = cleaned[:-2].partition(":") - hour, minute = int(hh), int(mm or 0) - except ValueError: - return raw - if not (0 <= hour <= 12 and 0 <= minute <= 59): - return raw - hour = hour % 12 + (12 if meridiem == "PM" else 0) - return f"{hour:02d}:{minute:02d}" - - def _scorebug_font(self, draw, text: str, width: int): - """The face this scorebug draws its date and time in. - - Always the "time" face, which is what this display has used for both - rows for as long as it has existed: changing switch_upcoming_center - moves the two lines around, it is not meant to restyle them, so the - type stays put while the placement changes. - - The single exception is text that cannot fit the panel at all. Only - the "weekday" date can do that -- "Fri Sep 19" measures 80px in an - 8px face, on a board 64px wide -- and the smaller "detail" face is a - better answer there than running off both edges. Every other date and - time this display can produce fits, so in practice the face never - changes; it is a floor, not a style rule. - """ - font = self.fonts["time"] - if not text: - return font - try: - if draw.textlength(text, font=font) + 2 <= width: - return font - except (TypeError, ValueError): - return font - return self.fonts.get("detail") or font - def _upcoming_date_and_time_text(self, game_date: str, game_time: str, game: Optional[Dict] = None) -> Tuple[str, str]: """The formatted (date, time) pair, blanked by show_date/show_time.""" @@ -834,106 +643,6 @@ def _upcoming_date_and_time_text(self, game_date: str, game_time: str, if self._card_option("show_time", True) else "") return date_text, time_text - def _draw_upcoming_center_switch(self, draw, game: Dict, center_y: int, - game_date: str, game_time: str, - display_width: Optional[int] = None, - display_height: Optional[int] = None, - date_element: str = 'date', - time_element: str = 'time', - second_row_y_offset: bool = True) -> bool: - """Draw the middle of the full-screen upcoming scorebug. - - Returns True when the header above it ("Next Game", or the league - name) should still be drawn. In "vs" and "none" the date and time move - out of the middle and into the top and bottom slots, mirroring the - scroll card -- and the top slot is where the header used to be, so the - caller drops it. - - ``date_element``/``time_element``/``second_row_y_offset`` exist only so - the layout-offset keys stay exactly what each plugin's schema - advertises; this sport's defaults are the common case. - """ - width = self.display_width if display_width is None else display_width - height = self.display_height if display_height is None else display_height - mode = self._switch_upcoming_center() - date_text, time_text = self._upcoming_date_and_time_text( - game_date, game_time, game) - swapped = bool(self._card_option("swap_date_time", False)) - - if mode == "date_time": - # Historically the date sat at center_y - 7 with the time 9px - # under it, and the time's row was derived from the date's, so a - # date y_offset moved the pair. Both still hold; the slots only - # trade places when swap_date_time is set, and hiding one line - # leaves the other where it was rather than re-centering the stack. - slots = [(time_element, time_text), (date_element, date_text)] if swapped \ - else [(date_element, date_text), (time_element, time_text)] - row_y = center_y - 7 - for index, (element, text) in enumerate(slots): - if index: - row_y += 9 - if second_row_y_offset: - row_y += self._get_layout_offset(element, 'y_offset') - else: - row_y += self._get_layout_offset(element, 'y_offset') - if not text: - continue - font = self._scorebug_font(draw, text, width) - text_width = draw.textlength(text, font=font) - text_x = ((width - text_width) // 2 - + self._get_layout_offset(element, 'x_offset')) - self._draw_text_with_outline( - draw, text, (text_x, row_y), font - ) - return True - - if mode == "vs": - vs_text = self._vs_text() - if vs_text: - vs_width = draw.textlength(vs_text, font=self.fonts["score"]) - vs_x = ((width - vs_width) // 2 - + self._get_layout_offset('score', 'x_offset')) - vs_y = (center_y - 3 - + self._get_layout_offset('score', 'y_offset')) - self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts["score"] - ) - - # "vs" and "none" both push the date and time out to the edges, time - # on top unless swap_date_time says otherwise -- the same order the - # scroll card uses. - if swapped: - top_element, top_text = date_element, date_text - bottom_element, bottom_text = time_element, time_text - else: - top_element, top_text = time_element, time_text - bottom_element, bottom_text = date_element, date_text - - if top_text: - top_font = self._scorebug_font(draw, top_text, width) - top_width = draw.textlength(top_text, font=top_font) - top_x = ((width - top_width) // 2 - + self._get_layout_offset(top_element, 'x_offset')) - top_y = 1 + self._get_layout_offset(top_element, 'y_offset') - self._draw_text_with_outline( - draw, top_text, (top_x, top_y), top_font - ) - if bottom_text: - bottom_font = self._scorebug_font(draw, bottom_text, width) - bottom_width = draw.textlength(bottom_text, font=bottom_font) - bottom_x = ((width - bottom_width) // 2 - + self._get_layout_offset(bottom_element, 'x_offset')) - # Measured, not a fixed offset: the detail font is 6px in most - # plugins and 10px in soccer and nrl, where a fixed -7 ran the - # date off the panel. - ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] - bottom_y = (max(0, height - ink_bottom - 1) - + self._get_layout_offset(bottom_element, 'y_offset')) - self._draw_text_with_outline( - draw, bottom_text, (bottom_x, bottom_y), bottom_font - ) - return False - def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: """ Get layout offset for a specific element and axis. @@ -982,86 +691,6 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: "tie": (255, 200, 0), } - @staticmethod - def _coerce_rgb(value, fallback): - """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" - # Checked before unpacking: a 3-character string ("123") would otherwise - # iterate into three digits and yield a colour rather than the fallback. - if not isinstance(value, (list, tuple)) or len(value) != 3: - return fallback - try: - r, g, b = (max(0, min(255, int(channel))) for channel in value) - except (TypeError, ValueError): - return fallback - return (r, g, b) - - @staticmethod - def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: - """Is the home/away side of this game a favorite team? - - Both the abbreviation and the ESPN id are checked, because a couple of - leagues (NRL) match favorites by id where abbreviations collide. - """ - for key in (f"{side}_abbr", f"{side}_id"): - value = game.get(key) - if value is not None and str(value).strip().upper() in favorites: - return True - return False - - def _favorite_result(self, game: Dict) -> Optional[str]: - """Say how the favorite team did in a finished game. - - Returns 'win', 'loss' or 'tie', or None when there is no single team - to root for: no favorites configured, neither side is a favorite, or - *both* are -- a favorite-vs-favorite game has no losing side worth - flagging in red. Also None when the scores are not usable numbers. - """ - favorites = getattr(self, "favorite_teams", None) or [] - favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} - if not favorites: - return None - - home_fav = self._side_is_favorite(game, "home", favorites) - away_fav = self._side_is_favorite(game, "away", favorites) - if home_fav == away_fav: - return None - - try: - # int(float(...)) to match GameRenderer._side_score exactly -- the - # two paths must agree on what counts as a usable score. - home_score = int(float(str(game.get("home_score", "")).strip())) - away_score = int(float(str(game.get("away_score", "")).strip())) - except (TypeError, ValueError): - return None - - if home_score == away_score: - return "tie" - favorite_score, other_score = ( - (home_score, away_score) if home_fav else (away_score, home_score) - ) - return "win" if favorite_score > other_score else "loss" - - def _recent_score_color(self, game: Dict, default): - """Fill color for a finished game's score, per favorite_result_colors.""" - try: - settings = (self.config.get("customization") or {}).get( - "favorite_result_colors" - ) or {} - if not settings.get("enabled", False): - return default - result = self._favorite_result(game) - if result is None: - return default - return self._coerce_rgb( - settings.get(f"{result}_color"), - self.FAVORITE_RESULT_COLOR_DEFAULTS[result], - ) - except Exception: - self.logger.debug( - "Could not resolve favorite result color", exc_info=True - ) - return default - #: How far each logo is shifted outward, off the panel edge, by the #: scorebug layouts (they paste at -2 and width - logo_width + 2). Kept #: here because the logo sizing has to know it. @@ -1209,137 +838,6 @@ def _fit_score_font(self, fonts): #: -- so on a taller panel they grew and the score did not. _FONT_DESIGN_HEIGHT: ClassVar[int] = 32 - def _score_font_size(self) -> int: - """Pixel size the score is currently drawn at.""" - return getattr(self.fonts.get("score"), "size", 8) or 8 - - def _time_font_size(self) -> int: - """Pixel size the clock/date face is currently drawn at.""" - return getattr(self.fonts.get("time"), "size", 8) or 8 - - def _user_chose_size(self, element_key: str) -> bool: - """True when customization..font_size is a real choice. - - The web UI's save flow writes the whole schema default block into - config.json on every save, whether or not the user touched that - section, so a size merely being PRESENT carries no intent. Only one - that differs from the schema default does. - """ - element = (self.config.get('customization', {}) or {}).get(element_key) or {} - configured = element.get('font_size') - if configured is None: - return False - try: - return int(configured) != self._schema_font_size(element_key) - except (TypeError, ValueError): - return False - - def _grid_scaled_size(self, font): - """(path, grid, size) for *font* regrown to this panel's height. - - None when the panel is at or below the design height (nothing to do), - or when the face has no known pixel grid -- a user-supplied font is - never second-guessed, because we do not know what it renders crisply - at. - """ - path = getattr(font, 'path', None) - base = getattr(font, 'size', None) - if not base or not isinstance(path, str): - return None - face = os.path.basename(path) - grid = self._FONT_PIXEL_GRID.get(self._FONT_NAME_ALIASES.get(face, face)) - if not grid: - return None - scale = float(self.display_height) / (self._FONT_DESIGN_HEIGHT or 32) - if scale <= 1.0: - return None - return path, grid, max(int(base), int(self._crisp_size(face, base * scale))) - - def _scale_headline_fonts(self, fonts): - """Grow the score with the panel, and hold the clock/date below it. - - The score is the one number the card exists to show, and it was the - only element not sized from the panel. Worse, it was not even bigger - than its neighbours: PressStart2P renders crisply on an 8px grid, so - the 10px default snapped to 8 -- the same 8 the period/clock above it - and the game date below it are drawn at. Three lines of identical - type, none of them the headline, which is what makes the score read as - lower priority than the time and the date rather than the point of the - card. - - So the score is sized from display_height and snapped to its face's - pixel grid (off the grid FreeType anti-aliases the strokes, and on an - LED matrix a part-lit pixel is a dim lamp rather than a soft edge), - then stepped back down that grid until it fits its share of the width. - The clock/date face is regrown the same way but held at least one grid - step below the score, so the ranking between them is visible rather - than implied. - - A 32-tall panel scales by exactly 1.0 and is left byte-identical; a - size the user set explicitly is never overridden. - """ - self._score_grew = False - if not self._DRAWS_SCORE: - # No score on this screen, so none of the sizing below is for it. - return fonts - try: - scaled = None if self._user_chose_size('score_text') else \ - self._grid_scaled_size(fonts.get('score')) - if scaled is not None: - path, grid, size = scaled - base = getattr(fonts['score'], 'size', size) or size - size = min(size, base * self._SCORE_MAX_GROWTH) - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - # Measured from a fixed five-character score rather than the - # live one, so the card does not resize when a side passes 9. - while size > grid: - if probe.textlength( - self._SCORE_PROBE_TEXT, - font=ImageFont.truetype(path, size)) <= budget: - break - size -= grid - if size != getattr(fonts['score'], 'size', size): - fonts['score'] = ImageFont.truetype(path, size) - self._score_grew = True - - if not self._score_grew and not self._user_chose_size('score_text') \ - and self.display_height > self._FONT_DESIGN_HEIGHT: - # PressStart2P could not grow inside the budget -- its next crisp - # size is simply too wide for this panel. A narrower face still - # can: 4x6-font at 14px is nearly as tall as PressStart2P at 16 - # and about half as wide. This matters beyond the score itself, - # because a card whose score never grows never reserves the - # centre either, so its logos stay at the uncapped 1.5x and are - # drawn straight over the score -- which is what a three-digit - # basketball score does on a 128x64 board. - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - current = getattr(fonts.get('score'), 'size', 0) or 0 - for _name, _size in self._NARROW_SCORE_RUNGS: - if _size <= current: - continue - _path = _resolve_font_path(f"assets/fonts/{_name}") - _candidate = ImageFont.truetype(_path, _size) - if probe.textlength(self._SCORE_PROBE_TEXT, - font=_candidate) <= budget: - fonts['score'] = _candidate - self._score_grew = True - break - - scaled = None if self._user_chose_size('period_text') else \ - self._grid_scaled_size(fonts.get('time')) - if scaled is not None: - path, grid, size = scaled - ceiling = getattr(fonts.get('score'), 'size', 0) or 0 - if ceiling and size >= ceiling: - size = max(grid, ceiling - grid) - if size != getattr(fonts['time'], 'size', size): - fonts['time'] = ImageFont.truetype(path, size) - except Exception: - self.logger.debug("Headline font scaling skipped", exc_info=True) - return fonts - def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1530,105 +1028,6 @@ def _draw_dynamic_odds( "rank": "rank_text", } - def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): - """Per-element text colour from customization..text_color.""" - try: - cfg = (self.config or {}).get("customization", {}).get(element, {}) - value = cfg.get("text_color") - if isinstance(value, (list, tuple)) and len(value) == 3: - return tuple(max(0, min(255, int(c))) for c in value) - if isinstance(value, str) and value.startswith("#") and len(value) == 7: - return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) - except (TypeError, ValueError): - pass - return default - - def _unshare_element_fonts(self, fonts): - """Give each colourable element its own face object. - - The colour a draw gets is resolved from the face it was handed, and - several of these loaders legitimately hand one object to more than one - element -- a size resolver that lands two elements on the same face, a - fallback that fills every key from one default, football's narrowing - step that deliberately shrinks the clock along with the score. Sharing - the object makes the element ambiguous and the colour unresolvable. - - Re-instantiating from the same path and size gives a distinct object - with identical metrics, so nothing about the rendering changes; only - the ability to tell two elements apart does. Faces that cannot be - rebuilt (a BDF loaded through freetype.Face, anything without a usable - path) are left shared, and their draws stay white as before. - """ - try: - from PIL import ImageFont as _IF - except ImportError: # pragma: no cover - return fonts - seen = {} - for key in self._ELEMENT_FOR_FONT: - font = fonts.get(key) - if font is None: - continue - if id(font) not in seen: - seen[id(font)] = key - continue - path, size = getattr(font, "path", None), getattr(font, "size", None) - if not path or not size: - continue - try: - fonts[key] = _IF.truetype(path, size) - except (OSError, ValueError, TypeError): - self.logger.debug( - "Could not un-share the %s face; it keeps the default colour", key) - return fonts - - def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): - """Colour for whichever element owns this face. - - Matched on identity, and deliberately gives up when one object is - shared: the last-resort font path can hand the same face to several - keys, and there is no right answer for which element's colour that is. - White is what those draws used before, so ambiguity costs nothing. - """ - try: - fonts = getattr(self, "fonts", None) or {} - matches = [element for key, element in self._ELEMENT_FOR_FONT.items() - if fonts.get(key) is font] - if len(matches) == 1: - return self._element_color(matches[0], default) - except (AttributeError, TypeError): - pass - return default - - def _draw_text_with_outline( - self, draw, text, position, font, fill=None, outline_color=(0, 0, 0) - ): - """Draw text with a black outline for better readability.""" - # Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get - # anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying - # glyphs. 1-bit mode keeps strokes crisp. - # Defaults to the configured colour for whichever element owns - # this face rather than to white, so customization..text_color - # reaches every draw. The schema has offered those pickers all along - # and they only ever changed the font. An explicit fill still wins: - # the odds colours and the favourite-result score tint mean something - # the palette does not. - if fill is None: - fill = self._font_color(font) - draw.fontmode = "1" - x, y = position - for dx, dy in [ - (-1, -1), - (-1, 0), - (-1, 1), - (0, -1), - (0, 1), - (1, -1), - (1, 0), - (1, 1), - ]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - def _load_and_resize_logo( self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]: @@ -1779,14 +1178,6 @@ def _get_timezone(self): log=self.logger, ) - def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: - """Check if we should log a warning based on cooldown period.""" - current_time = time.time() - if current_time - self._last_warning_time > cooldown: - self._last_warning_time = current_time - return True - return False - # Which ranking block the badge reads. ESPN answers /rankings with more # than one block for several leagues, and the FIRST is not always a poll: # men's and women's college hockey front "NCAA Men's/Women's Hockey @@ -2164,43 +1555,6 @@ def _fetch_todays_games(self) -> Optional[Dict]: ) return None - def _get_weeks_data(self) -> Optional[Dict]: - """ - Get partial data for immediate display while background fetch is in progress. - This fetches current/recent games only for quick response. - """ - try: - # Fetch current week and next few days for immediate display - now = datetime.now(pytz.utc) - immediate_events = [] - - start_date = now - timedelta(days=self.schedule_lookback_days) - end_date = now + timedelta(days=self.schedule_lookahead_days) - date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - response = self.session.get( - url, - params={"dates": date_str, "limit": 1000}, - headers=self.headers, - timeout=10, - ) - response.raise_for_status() - data = response.json() - immediate_events = data.get("events", []) - - if immediate_events: - self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") - return {"events": immediate_events} - - except requests.exceptions.RequestException as e: - self.logger.warning( - f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}" - ) - return None - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - pass - def _get_team_record_text(self, abbr: str, record: str) -> str: """Return the corner text for a team: ranking (if enabled/available) or record. @@ -2215,21 +1569,6 @@ def _get_team_record_text(self, abbr: str, record: str) -> str: return record return "" - def cleanup(self): - """Clean up resources when plugin is unloaded.""" - # Close HTTP session - if hasattr(self, 'session') and self.session: - try: - self.session.close() - except Exception as e: - self.logger.warning(f"Error closing session: {e}") - - # Clear caches - if hasattr(self, '_logo_cache'): - self._logo_cache.clear() - - self.logger.info(f"{self.__class__.__name__} cleanup completed") - def _is_favorite_game(self, game: Dict) -> bool: return bool(self.favorite_teams) and ( game.get("home_abbr") in self.favorite_teams @@ -2336,64 +1675,6 @@ def _load_division_team_ids(self) -> Dict[str, set]: self._division_team_ids[name] = ids return self._division_team_ids - def _game_divisions(self, game: Dict) -> Optional[set]: - """Divisions of BOTH sides, or None when they cannot be told. - - Both sides are collected, but the caller only needs ONE of them to sit - in a checked division. Requiring every participant read as "FBS games - only" and removed a ranked side hosting an FCS school -- which is still - a game involving a team the viewer checked the box for, and on a real - Week 2 slate it silently dropped five of the twenty ranked matchups. - What the checkbox is for is keeping FCS-versus-FCS out of a board - configured for FBS, and that still holds: a game with no checked - division on either side is dropped. - """ - divisions = self._load_division_team_ids() - if not any(divisions.values()): - return None - try: - ids = [int(game.get("home_id")), int(game.get("away_id"))] - except (TypeError, ValueError): - return None - present = set() - for team_id in ids: - for name in ("fbs", "fcs"): - if team_id in divisions.get(name, set()): - present.add(name) - break - else: - present.add("other") - return present - - def _league_has_rankings(self) -> bool: - """Only college leagues publish a poll; everyone else 404s. - - This gate matters more than it looks. _fetch_team_rankings only - short-circuits when the cache is non-empty, so a failed fetch leaves it - empty and the next update tries again -- at a 30s interval that is - ~2,900 pointless requests a day, per league, all of them 404s. - """ - league = (self.league or "").lower() - return "college" in league or "ncaa" in league - - @staticmethod - def _normalise_divisions(raw) -> List[str]: - """Division names from config, in the shape the filter expects. - - A hand-edited config can hold "fbs" where the schema says ["fbs"], and - list("fbs") is ['f', 'b', 's'] -- three names that match no division, so - every non-favourite game is rejected by a setting the user believes says - the opposite. An empty list is left empty: that means "no division - filter" and is a legitimate choice, not a mistake to correct. - """ - if isinstance(raw, str): - raw = [raw] - try: - items = list(raw or []) - except TypeError: - return [] - return [str(d).strip().lower() for d in items if str(d).strip()] - def _setting_int(self, key: str, default: int, low: int, high: int) -> int: """A count from config, clamped to the range its schema declares. @@ -2431,64 +1712,6 @@ def _best_rank(self, game: Dict) -> int: rankings.get(game.get("away_abbr"), 0)) if r] return min(ranked) if ranked else 99 - def _round_robin_favorites(self, games: List[Dict], limit: int) -> List[Dict]: - """Each favourite team's next game before any team's second one. - - Taking the soonest N favourite games spends the slots on whoever plays - most often. Walked across a real season with two favourites and a limit - of 2, nine days of it showed Auburn twice and Georgia not at all -- - Auburn played either side of a Georgia bye, so both slots went to - Auburn. The other-games pool already refuses to do this; favourites - were still doing it. - - Depth is kept where there is room: one favourite with three slots still - gets its next three games, because the round-robin only comes back for - a team's second game once every team has had a first. - - A game between two favourites is picked once and counts for both. - """ - if limit <= 0 or not games: - return [] - wanted = [t for t in (self.favorite_teams or []) if t] - if len(wanted) < 2: - return games[:limit] # nothing to share the slots between - - # Which side of a game belongs to which favourite is a per-lineage - # question: NRL matches on ESPN team IDs because its abbreviations are - # not unique ("NEW" is both Newcastle and New Zealand), while the rest - # match on abbreviation. Ask for the lineage's own matcher rather than - # assuming, or this silently groups nothing and every slot goes empty. - team_in = getattr(self, "_team_in", None) - if callable(team_in): - def belongs(game, team): - return bool(team_in(game.get("home_id"), [team]) - or team_in(game.get("away_id"), [team])) - else: - def belongs(game, team): - return team in (game.get("home_abbr"), game.get("away_abbr")) - - queues = {team: [] for team in wanted} - for game in games: # already in kickoff order - for team in wanted: - if belongs(game, team): - queues[team].append(game) - - picked, taken = [], set() - while len(picked) < limit: - progressed = False - for team in wanted: - queue = queues[team] - while queue and queue[0].get("id") in taken: - queue.pop(0) - if queue and len(picked) < limit: - game = queue.pop(0) - taken.add(game.get("id")) - picked.append(game) - progressed = True - if not progressed: - break # every queue is empty - return picked - def _by_importance(self, games: List[Dict], newest_first: bool = False) -> List[Dict]: """Non-favourite games, best matchup first. @@ -2553,35 +1776,6 @@ def key(game): #: migrates to "ranked" -- see _normalise_quality. _QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"}) - def _normalise_quality(self, raw) -> str: - """other_games_min_quality, as one of the values the code implements. - - An unusable value used to fall through every branch of - _passes_other_filters and silently mean "any" -- a quality bar the - board believes it has and does not. - """ - value = str(raw or "").strip().lower() - if value in self._QUALITY_CHOICES: - return value - if value == "broadcast": - # Retired in football-scoreboard 3.0.0 and now here. Measured - # against a real Week 1 and Week 2 college slate it passed 174 of - # 175 games: ESPN publishes a broadcaster for nearly everything - # now, ESPN+ included, so the tier read as a quality bar and - # behaved as "any". Boards holding it get the bar they thought - # they were getting. - self.logger.warning( - "%s: other_games_min_quality 'broadcast' has been retired -- " - "it let through nearly every game -- using 'ranked'. Change " - "the setting to clear this.", getattr(self, "sport_key", "?"), - ) - return "ranked" - self.logger.warning( - "%s: ignoring unusable other_games_min_quality=%r, using 'ranked'", - getattr(self, "sport_key", "?"), raw, - ) - return "ranked" - def _passes_other_filters(self, game: Dict) -> bool: """Is this non-favourite game worth one of the remaining slots? @@ -2621,44 +1815,6 @@ def _filtered_or_all(self, games: List[Dict]) -> List[Dict]: return kept or games - def _check_ranking_coverage(self, games: List[Dict]) -> None: - """Say so when a loaded poll matches nothing on the schedule. - - The table is keyed by the abbreviation the RANKINGS endpoint returns and - matched against the one the SCOREBOARD endpoint returns. Nothing - guarantees the two agree, and if they ever stop agreeing the filter - quietly removes every non-favourite game -- no exception, no log line, - just a shorter board. That is the same shape as the bug where rankings - were never loading at all, which survived until someone went looking. - - Throttled to once an hour: selection runs on every update. - """ - if self.other_games_min_quality != "ranked": - return - rankings = getattr(self, "_team_rankings_cache", None) or {} - if not rankings or not games: - return - if any(self._is_ranked_game(g) for g in games): - return - now = time.monotonic() - # Zero means never logged, not "logged at the epoch". monotonic() counts - # from an arbitrary origin -- on a freshly booted board it is a few - # hundred seconds -- so comparing against 0 swallowed the first warning - # for the first hour of uptime, which is exactly when a misconfigured - # board is being watched. CI caught this; a machine with days of uptime - # cannot. - if (self._ranking_coverage_logged_at - and now - self._ranking_coverage_logged_at < self._RANKING_COVERAGE_SECONDS): - return - self._ranking_coverage_logged_at = now - self.logger.warning( - "%s: %d ranked teams loaded, but none of the %d other games match " - "one -- the quality filter is removing every non-favourite game. " - "Ranked abbreviations look like: %s", - self.league, len(rankings), len(games), - ", ".join(sorted(rankings)[:8]), - ) - def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: """A rotating slice of the non-favourite games. @@ -2697,98 +1853,6 @@ def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: window += others[:limit - len(window)] return window - def _favorites_first( - self, - processed_games: List[Dict], - favorite_limit: int, - other_limit: int, - newest_first: bool = False, - ) -> List[Dict]: - """Favourite games first, then a bounded number of everything else. - - This is the middle setting the plugin was missing. `show_favorite_teams_only` - used to be the whole story: on, and you saw nothing but your teams; off, - and your teams were ignored entirely -- the selection just took the next - N games league-wide, so a UGA fan with 946 upcoming college games in the - window saw UGA about as often as chance allowed. - - Both counts are TOTALS here, not per-team. In favourites-only mode - `upcoming_games_to_show` is a per-team budget, which is reasonable when - the list is your own teams; applied to a dynamic group it is not. With - AP_TOP_10 resolving to a dozen teams, three games each is 28 distinct - cards before a single non-favourite is added. A total keeps the rotation - the length the user asked for. - """ - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key, reverse=True) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key) - - favorites, others, unfiltered = [], [], [] - for game in ordered: - if self._is_favorite_game(game): - favorites.append(game) # never filtered: your team is your team - continue - unfiltered.append(game) - if self._passes_other_filters(game): - others.append(game) - self._check_ranking_coverage(unfiltered) - - self._selection_pools = { - "favorites": favorites, - "others": self._by_importance(others, newest_first), - "unfiltered": self._by_importance(unfiltered, newest_first), - "favorite_limit": favorite_limit, - "other_limit": other_limit, - "newest_first": newest_first, - } - return self._compose_selection() - - def _compose_selection(self) -> List[Dict]: - """Favourites plus the current slice of others, in schedule order. - - Split out of _favorites_first so the slice can be re-cut between - fetches. The pools are settled -- which games exist, and which of them - are worth a slot -- while WHICH of the others is on screen is a display - decision, and gating it on the fetch made the rotation interval a lie: - update() returns early until upcoming_update_interval has passed, so a - four-minute rotation actually stepped fifteen windows once an hour. - Same lesson as _advance_live_game_if_due further down this file. - """ - pools = self._selection_pools - favorites, others = pools["favorites"], pools["others"] - favorite_limit, other_limit = pools["favorite_limit"], pools["other_limit"] - newest_first = pools["newest_first"] - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - - selected = self._round_robin_favorites(favorites, max(0, favorite_limit)) - selected.extend(self._other_games_window(others, max(0, other_limit))) - if not selected and other_limit > 0: - # Nothing survived at all: your teams are not playing inside the - # schedule window AND the filters removed every other game. Each - # check fails open on missing data, but a filter working exactly as - # asked can still match nothing on a given day, and with no - # favourite game left there is nothing to carry the mode -- an empty - # list is a blank panel, not a short one. Same whole-list fallback - # `_filtered_or_all` makes for a board with no favourites at all. - # `other_limit` of 0 is an explicit "favourites only", so that one - # is left to go quiet as asked. - selected = self._other_games_window(pools["unfiltered"], max(0, other_limit)) - # Re-sort so the card order still reads as a schedule. Selection decides - # WHICH games; it should not reorder them into favourites-then-others, - # which would show next week's UGA game before tonight's. - selected.sort(key=key, reverse=newest_first) - return selected - def _rotate_other_games_on_display(self) -> bool: """Swap in a freshly cut slice when the rotation interval has passed. @@ -3418,39 +2482,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsRecent(SportsCore): - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mode_config.get( - "recent_update_interval", 3600 - ) # Check for recent games every hour - self.last_game_switch = 0 - self.game_display_duration = self.mode_config.get("recent_game_duration", 15) - self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 - - def _get_zero_clock_duration(self, game_id: str) -> float: - """Track how long a game has been at 0:00 clock.""" - current_time = time.time() - if game_id not in self._zero_clock_timestamps: - self._zero_clock_timestamps[game_id] = current_time - return 0.0 - return current_time - self._zero_clock_timestamps[game_id] - - def _clear_zero_clock_tracking(self, game_id: str) -> None: - """Clear tracking when game clock moves away from 0:00 or game ends.""" - if game_id in self._zero_clock_timestamps: - del self._zero_clock_timestamps[game_id] +class SportsRecent(SportsRecentSharedMixin, SportsCore): def _select_recent_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] @@ -4013,7 +3045,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -4544,76 +3576,6 @@ def _is_game_really_over(self, game: Dict) -> bool: ) return False - def _detect_stale_games(self, games: List[Dict]) -> None: - """Remove games that appear stale or haven't updated.""" - current_time = time.time() - - for game in games[:]: # Copy list to iterate safely - game_id = game.get("id") - if not game_id: - continue - - # Check if game data is stale - timestamps = self.game_update_timestamps.get(game_id, {}) - last_seen = timestamps.get("last_seen", 0) - - if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: - self.logger.warning( - f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(last seen {int(current_time - last_seen)}s ago)" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - continue - - # Also check if game appears to be over - if self._is_game_really_over(game): - self.logger.debug( - f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - - def _idle_live_interval(self) -> int: - """How long to wait before looking for live games again, when there are none. - - Escalates the longer nothing turns up, and any live game resets it, so - an in-season gap between games costs at most one escalated wait while - an out-of-season league stops polling on a live cadence entirely. - - Capped rather than unbounded: the cost of backing off is how late the - first game after a quiet spell is noticed, and past the cap the saving - stops being worth that. - """ - streak = getattr(self, "_empty_live_streak", 0) - base = self.no_data_interval - ceiling = getattr(self, "live_idle_max_interval", - _DEFAULT_LIVE_IDLE_MAX_SECONDS) - # The ceiling bounds the un-escalated interval too. The two settings are - # independent integers with no cross-validation, so base > ceiling is a - # reachable config -- and returning base unclamped there made the wait - # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), - # the opposite of what the setting named "maximum" promises. - if streak >= _IDLE_LONG_STREAK: - return min(int(base * _IDLE_LONG_FACTOR), ceiling) - if streak >= _IDLE_SHORT_STREAK: - return min(int(base * _IDLE_SHORT_FACTOR), ceiling) - return min(base, ceiling) - - def _note_live_fetch(self, found_live: bool) -> None: - """Record whether a look for live games found any.""" - if found_live: - if getattr(self, "_empty_live_streak", 0): - self.logger.info( - "Live games found after %d empty check(s); back to the " - "live update interval", self._empty_live_streak) - self._empty_live_streak = 0 - else: - self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 - def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: diff --git a/plugins/afl-scoreboard/test_afl_plugin.py b/plugins/afl-scoreboard/test_afl_plugin.py index 2c673114..477bfd32 100644 --- a/plugins/afl-scoreboard/test_afl_plugin.py +++ b/plugins/afl-scoreboard/test_afl_plugin.py @@ -17,6 +17,7 @@ from __future__ import annotations import logging +import os import sys import types from pathlib import Path @@ -83,6 +84,23 @@ def _install_host_stubs() -> None: sys.modules["src.logo_downloader"].LogoDownloader = object sys.modules["src.logo_downloader"].download_missing_logo = lambda *a, **k: None + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + _install_thirdparty_stubs() _install_host_stubs() diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index f71a76b1..30b0272b 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.37.1", + "version": "1.39.0", "update_interval": 60, "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", @@ -31,6 +31,13 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.39.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "The sports.py logic every scoreboard shares moves to the core. Forty-five method bodies here were byte-identical to the same forty-five in every other scoreboard -- the selection and rotation engine, the font, colour and date subsystem, and the switch-mode upcoming card -- so they now come from src.common.sports_shared and this plugin inherits them. 1041 lines removed here. A fix to any of that now reaches every scoreboard at once instead of needing eight identical edits. Three deliberately stayed behind: _get_timezone, because it binds a per-plugin timezone module whose contents differ, and the two abstract stubs that define what makes this sport its own. Nothing drawn changes -- the bodies moved rather than being rewritten, and all 176 safety-harness renders across the eight plugins are byte-identical to before. The floor rises to 3.3.0, the release that first ships the shared module.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.37.1", "released": "2026-09-02", @@ -354,7 +361,7 @@ { "released": "2026-07-08", "version": "1.14.2", - "notes": "Shrink the Traditional Scoreboard's ball/strike/out circle indicators further (they were still a bit overpowering) and move the batting-team \u25b2/\u25bc indicator out of the At Bat column into the header row's empty team-column cell, right next to the inning numbers.", + "notes": "Shrink the Traditional Scoreboard's ball/strike/out circle indicators further (they were still a bit overpowering) and move the batting-team ▲/▼ indicator out of the At Bat column into the header row's empty team-column cell, right next to the inning numbers.", "ledmatrix_min": "2.0.0" }, { @@ -372,7 +379,7 @@ { "released": "2026-07-07", "version": "1.13.1", - "notes": "Fix the Traditional Scoreboard's At Bat side panel (added in 1.13.0) clipping its ball/strike/out dots off the right edge of the display -- the fit check compared leftover space against a flush-left grid, but the grid is actually centered, so it was eating into the panel's reserved space from the left too. Also account for the Outs row's extra batting-team \u25b2/\u25bc arrow, which wasn't factored into the width check at all.", + "notes": "Fix the Traditional Scoreboard's At Bat side panel (added in 1.13.0) clipping its ball/strike/out dots off the right edge of the display -- the fit check compared leftover space against a flush-left grid, but the grid is actually centered, so it was eating into the panel's reserved space from the left too. Also account for the Outs row's extra batting-team ▲/▼ arrow, which wasn't factored into the width check at all.", "ledmatrix_min": "2.0.0" }, { @@ -414,7 +421,7 @@ { "released": "2026-07-02", "version": "1.7.0", - "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores \u2014 spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league.", + "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores — spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league.", "ledmatrix_min": "2.0.0" }, { @@ -540,6 +547,6 @@ "class_name": "BaseballScoreboardPlugin", "entry_point": "manager.py", "compatible_versions": [ - ">=3.2.0" + ">=3.3.0" ] } diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index f789ca20..3fa6f715 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -51,6 +51,8 @@ def resolve_font_name(font_name: str) -> str: from base_odds_manager import BaseOddsManager from data_sources import ESPNDataSource from baseball_timezone import resolve_timezone +from src.common.sports_shared import ( + SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin) def _resolve_font_path(path: str) -> str: @@ -96,7 +98,6 @@ def _resolve_font_path(path: str) -> str: return path - _DEFAULT_LOOKBACK_DAYS = 14 _DEFAULT_LOOKAHEAD_DAYS = 7 _MIN_WINDOW_DAYS = 1 @@ -165,7 +166,7 @@ def _logo_needs_refresh(logo_file) -> bool: return False -class SportsCore(ABC): +class SportsCore(SportsCoreSharedMixin, ABC): def __init__( self, config: Dict[str, Any], @@ -362,24 +363,6 @@ def __init__( "Background service not available - using synchronous fetching" ) - def _get_season_schedule_dates(self) -> tuple[str, str]: - return "", "" - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Placeholder draw method - subclasses should override.""" - # This base method will be simple, subclasses provide specifics - try: - img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - status = game.get("status_text", "N/A") - self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) - self.display_manager.image.paste(img, (0, 0)) - # Don't call update_display here, let subclasses handle it after drawing - except Exception as e: - self.logger.error( - f"Error in base _draw_scorebug_layout: {e}", exc_info=True - ) - def display(self, force_clear: bool = False) -> bool: """Render the current game. Returns False when nothing can be shown.""" if not self.is_enabled: # Check if module is enabled @@ -429,62 +412,6 @@ def display(self, force_clear: bool = False) -> bool: 'four_by_six': '4x6-font.ttf', } - @classmethod - def _crisp_size(cls, font_file, desired): - """Snap *desired* to the nearest size *font_file* renders crisply at. - - A face with no known grid is returned unchanged, so a user-supplied - font is never second-guessed. - """ - font_file = cls._FONT_NAME_ALIASES.get(font_file, font_file) - grid = cls._FONT_PIXEL_GRID.get(font_file) - if not grid or not desired or desired <= 0: - return desired - return max(grid, int(round(float(desired) / grid)) * grid) - - def _schema_font_size(self, element_key): - """The font_size this plugin's config_schema.json declares, or None.""" - if not element_key: - return None - cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None) - if cache is None: - cache = {} - try: - import json - schema_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'config_schema.json') - with open(schema_path) as fh: - schema = json.load(fh) - props = (schema.get('properties', {}) - .get('customization', {}) - .get('properties', {})) - for key, spec in props.items(): - size = spec.get('properties', {}).get('font_size', {}).get('default') - if size is not None: - cache[key] = int(size) - except Exception: - cache = {} - self.__class__._SCHEMA_FONT_SIZES = cache - return cache.get(element_key) - - def _resolve_font_size(self, element_config, element_key, default_size, font_name): - """Size to render at: the user's choice, or a grid-snapped default. - - A configured size counts as a real choice only when it differs from - the schema default. The web UI writes the whole schema default block - on every save, so "font_size == schema default" carries no intent and - would otherwise pin every install to an anti-aliased size forever. - """ - configured = (element_config or {}).get('font_size') - if configured is not None: - try: - configured = int(configured) - if configured != self._schema_font_size(element_key): - return configured - except (TypeError, ValueError): - pass - return self._crisp_size(font_name, default_size) - def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], default_size: int = 8, element_key=None, default_font: Optional[str] = None) -> ImageFont.FreeTypeFont: """ Load a custom font from an element configuration dictionary. @@ -644,124 +571,6 @@ def _read_bdf_native_size(bdf_path: str) -> Optional[int]: "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", ) - def _card_option(self, key: str, default: Any = None) -> Any: - """Read one key from the scroll_card config block.""" - block = (self.config or {}).get("scroll_card") - if isinstance(block, dict) and block.get(key) is not None: - return block.get(key) - return default - - def _switch_upcoming_center(self) -> str: - """Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'.""" - mode = str(self._card_option("switch_upcoming_center", "date_time") - or "date_time").lower() - if mode == "inherit": - mode = str(self._card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time", "none") else "date_time" - - def _vs_text(self) -> str: - """Separator drawn between the teams -- "VS", "@", "at", anything.""" - return str(self._card_option("vs_text", "VS")) - - def _switch_date_format(self) -> str: - """Date style for the full-screen scorebug. - - Its own key rather than the shared ``date_format`` because the two - displays disagree about the default: the scroll card renders "Sep 19" - while _extract_game_details_common emits "9/19", the "numeric" style, - and this scorebug has always drawn it. Reading the shared key here - would restyle every existing panel on update -- and "leave it alone - when unset" is not available, because the core merges schema defaults - into the config on every load, so the key is never actually unset. - "inherit" opts into the scroll and Vegas setting. - """ - fmt = str(self._card_option("switch_date_format", "numeric") or "numeric").lower() - if fmt == "inherit": - fmt = str(self._card_option("date_format", "abbrev") or "abbrev").lower() - return fmt - - def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: - """Format an upcoming date per scroll_card.switch_date_format.""" - raw = str(date_text or "").strip() - if not raw: - return raw - fmt = self._switch_date_format() - if fmt == "numeric": - return raw - parts = raw.replace("-", "/").split("/") - if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): - return raw - month, day = int(parts[0]), int(parts[1]) - if not 1 <= month <= 12: - return raw - name = self._MONTH_ABBR[month - 1] - if fmt == "numeric_day_first": - return f"{day}/{month}" - if fmt == "day_first": - return f"{day} {name}" - if fmt == "weekday": - weekday = self._weekday_for(game) - return f"{weekday} {name} {day}" if weekday else f"{name} {day}" - return f"{name} {day}" - - def _weekday_for(self, game: Optional[Dict]) -> str: - """Weekday abbreviation from the game's start time, or ''.""" - if not game: - return "" - raw = game.get("start_time_utc") or game.get("start_time") - if not raw: - return "" - try: - start = raw if isinstance(raw, datetime) else datetime.fromisoformat( - str(raw).replace("Z", "+00:00")) - return self._WEEKDAY_ABBR[start.astimezone(self._get_timezone()).weekday()] - except (ValueError, TypeError, OverflowError): - return "" - - def _format_game_time(self, time_text: str) -> str: - """Return the time as-is (12h) or converted to 24h.""" - raw = str(time_text or "").strip() - if not raw or str(self._card_option("time_format", "12h")) != "24h": - return raw - cleaned = raw.upper().replace(" ", "") - meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" - if not meridiem: - return raw - try: - hh, _, mm = cleaned[:-2].partition(":") - hour, minute = int(hh), int(mm or 0) - except ValueError: - return raw - if not (0 <= hour <= 12 and 0 <= minute <= 59): - return raw - hour = hour % 12 + (12 if meridiem == "PM" else 0) - return f"{hour:02d}:{minute:02d}" - - def _scorebug_font(self, draw, text: str, width: int): - """The face this scorebug draws its date and time in. - - Always the "time" face, which is what this display has used for both - rows for as long as it has existed: changing switch_upcoming_center - moves the two lines around, it is not meant to restyle them, so the - type stays put while the placement changes. - - The single exception is text that cannot fit the panel at all. Only - the "weekday" date can do that -- "Fri Sep 19" measures 80px in an - 8px face, on a board 64px wide -- and the smaller "detail" face is a - better answer there than running off both edges. Every other date and - time this display can produce fits, so in practice the face never - changes; it is a floor, not a style rule. - """ - font = self.fonts["time"] - if not text: - return font - try: - if draw.textlength(text, font=font) + 2 <= width: - return font - except (TypeError, ValueError): - return font - return self.fonts.get("detail") or font - def _upcoming_date_and_time_text(self, game_date: str, game_time: str, game: Optional[Dict] = None) -> Tuple[str, str]: """The formatted (date, time) pair, blanked by show_date/show_time.""" @@ -771,106 +580,6 @@ def _upcoming_date_and_time_text(self, game_date: str, game_time: str, if self._card_option("show_time", True) else "") return date_text, time_text - def _draw_upcoming_center_switch(self, draw, game: Dict, center_y: int, - game_date: str, game_time: str, - display_width: Optional[int] = None, - display_height: Optional[int] = None, - date_element: str = 'date', - time_element: str = 'time', - second_row_y_offset: bool = True) -> bool: - """Draw the middle of the full-screen upcoming scorebug. - - Returns True when the header above it ("Next Game", or the league - name) should still be drawn. In "vs" and "none" the date and time move - out of the middle and into the top and bottom slots, mirroring the - scroll card -- and the top slot is where the header used to be, so the - caller drops it. - - ``date_element``/``time_element``/``second_row_y_offset`` exist only so - the layout-offset keys stay exactly what each plugin's schema - advertises; this sport's defaults are the common case. - """ - width = self.display_width if display_width is None else display_width - height = self.display_height if display_height is None else display_height - mode = self._switch_upcoming_center() - date_text, time_text = self._upcoming_date_and_time_text( - game_date, game_time, game) - swapped = bool(self._card_option("swap_date_time", False)) - - if mode == "date_time": - # Historically the date sat at center_y - 7 with the time 9px - # under it, and the time's row was derived from the date's, so a - # date y_offset moved the pair. Both still hold; the slots only - # trade places when swap_date_time is set, and hiding one line - # leaves the other where it was rather than re-centering the stack. - slots = [(time_element, time_text), (date_element, date_text)] if swapped \ - else [(date_element, date_text), (time_element, time_text)] - row_y = center_y - 7 - for index, (element, text) in enumerate(slots): - if index: - row_y += 9 - if second_row_y_offset: - row_y += self._get_layout_offset(element, 'y_offset') - else: - row_y += self._get_layout_offset(element, 'y_offset') - if not text: - continue - font = self._scorebug_font(draw, text, width) - text_width = draw.textlength(text, font=font) - text_x = ((width - text_width) // 2 - + self._get_layout_offset(element, 'x_offset')) - self._draw_text_with_outline( - draw, text, (text_x, row_y), font - ) - return True - - if mode == "vs": - vs_text = self._vs_text() - if vs_text: - vs_width = draw.textlength(vs_text, font=self.fonts["score"]) - vs_x = ((width - vs_width) // 2 - + self._get_layout_offset('score', 'x_offset')) - vs_y = (center_y - 3 - + self._get_layout_offset('score', 'y_offset')) - self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts["score"] - ) - - # "vs" and "none" both push the date and time out to the edges, time - # on top unless swap_date_time says otherwise -- the same order the - # scroll card uses. - if swapped: - top_element, top_text = date_element, date_text - bottom_element, bottom_text = time_element, time_text - else: - top_element, top_text = time_element, time_text - bottom_element, bottom_text = date_element, date_text - - if top_text: - top_font = self._scorebug_font(draw, top_text, width) - top_width = draw.textlength(top_text, font=top_font) - top_x = ((width - top_width) // 2 - + self._get_layout_offset(top_element, 'x_offset')) - top_y = 1 + self._get_layout_offset(top_element, 'y_offset') - self._draw_text_with_outline( - draw, top_text, (top_x, top_y), top_font - ) - if bottom_text: - bottom_font = self._scorebug_font(draw, bottom_text, width) - bottom_width = draw.textlength(bottom_text, font=bottom_font) - bottom_x = ((width - bottom_width) // 2 - + self._get_layout_offset(bottom_element, 'x_offset')) - # Measured, not a fixed offset: the detail font is 6px in most - # plugins and 10px in soccer and nrl, where a fixed -7 ran the - # date off the panel. - ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] - bottom_y = (max(0, height - ink_bottom - 1) - + self._get_layout_offset(bottom_element, 'y_offset')) - self._draw_text_with_outline( - draw, bottom_text, (bottom_x, bottom_y), bottom_font - ) - return False - def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: """ Get layout offset for a specific element and axis. @@ -924,86 +633,6 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: "tie": (255, 200, 0), } - @staticmethod - def _coerce_rgb(value, fallback): - """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" - # Checked before unpacking: a 3-character string ("123") would otherwise - # iterate into three digits and yield a colour rather than the fallback. - if not isinstance(value, (list, tuple)) or len(value) != 3: - return fallback - try: - r, g, b = (max(0, min(255, int(channel))) for channel in value) - except (TypeError, ValueError): - return fallback - return (r, g, b) - - @staticmethod - def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: - """Is the home/away side of this game a favorite team? - - Both the abbreviation and the ESPN id are checked, because a couple of - leagues (NRL) match favorites by id where abbreviations collide. - """ - for key in (f"{side}_abbr", f"{side}_id"): - value = game.get(key) - if value is not None and str(value).strip().upper() in favorites: - return True - return False - - def _favorite_result(self, game: Dict) -> Optional[str]: - """Say how the favorite team did in a finished game. - - Returns 'win', 'loss' or 'tie', or None when there is no single team - to root for: no favorites configured, neither side is a favorite, or - *both* are -- a favorite-vs-favorite game has no losing side worth - flagging in red. Also None when the scores are not usable numbers. - """ - favorites = getattr(self, "favorite_teams", None) or [] - favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} - if not favorites: - return None - - home_fav = self._side_is_favorite(game, "home", favorites) - away_fav = self._side_is_favorite(game, "away", favorites) - if home_fav == away_fav: - return None - - try: - # int(float(...)) to match GameRenderer._side_score exactly -- the - # two paths must agree on what counts as a usable score. - home_score = int(float(str(game.get("home_score", "")).strip())) - away_score = int(float(str(game.get("away_score", "")).strip())) - except (TypeError, ValueError): - return None - - if home_score == away_score: - return "tie" - favorite_score, other_score = ( - (home_score, away_score) if home_fav else (away_score, home_score) - ) - return "win" if favorite_score > other_score else "loss" - - def _recent_score_color(self, game: Dict, default): - """Fill color for a finished game's score, per favorite_result_colors.""" - try: - settings = (self.config.get("customization") or {}).get( - "favorite_result_colors" - ) or {} - if not settings.get("enabled", False): - return default - result = self._favorite_result(game) - if result is None: - return default - return self._coerce_rgb( - settings.get(f"{result}_color"), - self.FAVORITE_RESULT_COLOR_DEFAULTS[result], - ) - except Exception: - self.logger.debug( - "Could not resolve favorite result color", exc_info=True - ) - return default - #: How far each logo is shifted outward, off the panel edge, by the #: scorebug layouts (they paste at -2 and width - logo_width + 2). Kept #: here because the logo sizing has to know it. @@ -1151,137 +780,6 @@ def _fit_score_font(self, fonts): #: -- so on a taller panel they grew and the score did not. _FONT_DESIGN_HEIGHT: ClassVar[int] = 32 - def _score_font_size(self) -> int: - """Pixel size the score is currently drawn at.""" - return getattr(self.fonts.get("score"), "size", 8) or 8 - - def _time_font_size(self) -> int: - """Pixel size the clock/date face is currently drawn at.""" - return getattr(self.fonts.get("time"), "size", 8) or 8 - - def _user_chose_size(self, element_key: str) -> bool: - """True when customization..font_size is a real choice. - - The web UI's save flow writes the whole schema default block into - config.json on every save, whether or not the user touched that - section, so a size merely being PRESENT carries no intent. Only one - that differs from the schema default does. - """ - element = (self.config.get('customization', {}) or {}).get(element_key) or {} - configured = element.get('font_size') - if configured is None: - return False - try: - return int(configured) != self._schema_font_size(element_key) - except (TypeError, ValueError): - return False - - def _grid_scaled_size(self, font): - """(path, grid, size) for *font* regrown to this panel's height. - - None when the panel is at or below the design height (nothing to do), - or when the face has no known pixel grid -- a user-supplied font is - never second-guessed, because we do not know what it renders crisply - at. - """ - path = getattr(font, 'path', None) - base = getattr(font, 'size', None) - if not base or not isinstance(path, str): - return None - face = os.path.basename(path) - grid = self._FONT_PIXEL_GRID.get(self._FONT_NAME_ALIASES.get(face, face)) - if not grid: - return None - scale = float(self.display_height) / (self._FONT_DESIGN_HEIGHT or 32) - if scale <= 1.0: - return None - return path, grid, max(int(base), int(self._crisp_size(face, base * scale))) - - def _scale_headline_fonts(self, fonts): - """Grow the score with the panel, and hold the clock/date below it. - - The score is the one number the card exists to show, and it was the - only element not sized from the panel. Worse, it was not even bigger - than its neighbours: PressStart2P renders crisply on an 8px grid, so - the 10px default snapped to 8 -- the same 8 the period/clock above it - and the game date below it are drawn at. Three lines of identical - type, none of them the headline, which is what makes the score read as - lower priority than the time and the date rather than the point of the - card. - - So the score is sized from display_height and snapped to its face's - pixel grid (off the grid FreeType anti-aliases the strokes, and on an - LED matrix a part-lit pixel is a dim lamp rather than a soft edge), - then stepped back down that grid until it fits its share of the width. - The clock/date face is regrown the same way but held at least one grid - step below the score, so the ranking between them is visible rather - than implied. - - A 32-tall panel scales by exactly 1.0 and is left byte-identical; a - size the user set explicitly is never overridden. - """ - self._score_grew = False - if not self._DRAWS_SCORE: - # No score on this screen, so none of the sizing below is for it. - return fonts - try: - scaled = None if self._user_chose_size('score_text') else \ - self._grid_scaled_size(fonts.get('score')) - if scaled is not None: - path, grid, size = scaled - base = getattr(fonts['score'], 'size', size) or size - size = min(size, base * self._SCORE_MAX_GROWTH) - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - # Measured from a fixed five-character score rather than the - # live one, so the card does not resize when a side passes 9. - while size > grid: - if probe.textlength( - self._SCORE_PROBE_TEXT, - font=ImageFont.truetype(path, size)) <= budget: - break - size -= grid - if size != getattr(fonts['score'], 'size', size): - fonts['score'] = ImageFont.truetype(path, size) - self._score_grew = True - - if not self._score_grew and not self._user_chose_size('score_text') \ - and self.display_height > self._FONT_DESIGN_HEIGHT: - # PressStart2P could not grow inside the budget -- its next crisp - # size is simply too wide for this panel. A narrower face still - # can: 4x6-font at 14px is nearly as tall as PressStart2P at 16 - # and about half as wide. This matters beyond the score itself, - # because a card whose score never grows never reserves the - # centre either, so its logos stay at the uncapped 1.5x and are - # drawn straight over the score -- which is what a three-digit - # basketball score does on a 128x64 board. - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - current = getattr(fonts.get('score'), 'size', 0) or 0 - for _name, _size in self._NARROW_SCORE_RUNGS: - if _size <= current: - continue - _path = _resolve_font_path(f"assets/fonts/{_name}") - _candidate = ImageFont.truetype(_path, _size) - if probe.textlength(self._SCORE_PROBE_TEXT, - font=_candidate) <= budget: - fonts['score'] = _candidate - self._score_grew = True - break - - scaled = None if self._user_chose_size('period_text') else \ - self._grid_scaled_size(fonts.get('time')) - if scaled is not None: - path, grid, size = scaled - ceiling = getattr(fonts.get('score'), 'size', 0) or 0 - if ceiling and size >= ceiling: - size = max(grid, ceiling - grid) - if size != getattr(fonts['time'], 'size', size): - fonts['time'] = ImageFont.truetype(path, size) - except Exception: - self.logger.debug("Headline font scaling skipped", exc_info=True) - return fonts - def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1475,106 +973,6 @@ def _draw_dynamic_odds( "rank": "rank_text", } - def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): - """Per-element text colour from customization..text_color.""" - try: - cfg = (self.config or {}).get("customization", {}).get(element, {}) - value = cfg.get("text_color") - if isinstance(value, (list, tuple)) and len(value) == 3: - return tuple(max(0, min(255, int(c))) for c in value) - if isinstance(value, str) and value.startswith("#") and len(value) == 7: - return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) - except (TypeError, ValueError): - pass - return default - - def _unshare_element_fonts(self, fonts): - """Give each colourable element its own face object. - - The colour a draw gets is resolved from the face it was handed, and - several of these loaders legitimately hand one object to more than one - element -- a size resolver that lands two elements on the same face, a - fallback that fills every key from one default, football's narrowing - step that deliberately shrinks the clock along with the score. Sharing - the object makes the element ambiguous and the colour unresolvable. - - Re-instantiating from the same path and size gives a distinct object - with identical metrics, so nothing about the rendering changes; only - the ability to tell two elements apart does. Faces that cannot be - rebuilt (a BDF loaded through freetype.Face, anything without a usable - path) are left shared, and their draws stay white as before. - """ - try: - from PIL import ImageFont as _IF - except ImportError: # pragma: no cover - return fonts - seen = {} - for key in self._ELEMENT_FOR_FONT: - font = fonts.get(key) - if font is None: - continue - if id(font) not in seen: - seen[id(font)] = key - continue - path, size = getattr(font, "path", None), getattr(font, "size", None) - if not path or not size: - continue - try: - fonts[key] = _IF.truetype(path, size) - except (OSError, ValueError, TypeError): - self.logger.debug( - "Could not un-share the %s face; it keeps the default colour", key) - return fonts - - def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): - """Colour for whichever element owns this face. - - Matched on identity, and deliberately gives up when one object is - shared: the last-resort font path can hand the same face to several - keys, and there is no right answer for which element's colour that is. - White is what those draws used before, so ambiguity costs nothing. - """ - try: - fonts = getattr(self, "fonts", None) or {} - matches = [element for key, element in self._ELEMENT_FOR_FONT.items() - if fonts.get(key) is font] - if len(matches) == 1: - return self._element_color(matches[0], default) - except (AttributeError, TypeError): - pass - return default - - def _draw_text_with_outline( - self, draw, text, position, font, fill=None, outline_color=(0, 0, 0) - ): - """Draw text with a black outline for better readability.""" - # Disable anti-aliasing: the scoreboard uses pixel/bitmap fonts (e.g. - # PressStart2P) which FreeType anti-aliases into dim partial-lit pixels - # on a 1:1 LED matrix, muddying glyphs (a "6" can read as "G"). Drawing - # in 1-bit mode keeps strokes crisp and legible. - # Defaults to the configured colour for whichever element owns - # this face rather than to white, so customization..text_color - # reaches every draw. The schema has offered those pickers all along - # and they only ever changed the font. An explicit fill still wins: - # the odds colours and the favourite-result score tint mean something - # the palette does not. - if fill is None: - fill = self._font_color(font) - draw.fontmode = "1" - x, y = position - for dx, dy in [ - (-1, -1), - (-1, 0), - (-1, 1), - (0, -1), - (0, 1), - (1, -1), - (1, 0), - (1, 1), - ]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - def _load_and_resize_logo( self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]: @@ -1739,14 +1137,6 @@ def _get_timezone(self): log=self.logger, ) - def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: - """Check if we should log a warning based on cooldown period.""" - current_time = time.time() - if current_time - self._last_warning_time > cooldown: - self._last_warning_time = current_time - return True - return False - # Which ranking block the badge reads. ESPN answers /rankings with more # than one block for several leagues, and the FIRST is not always a poll: # men's and women's college hockey front "NCAA Men's/Women's Hockey @@ -2047,58 +1437,6 @@ def _fetch_todays_games(self) -> Optional[Dict]: ) return None - def _get_weeks_data(self) -> Optional[Dict]: - """ - Get partial data for immediate display while background fetch is in progress. - This fetches current/recent games only for quick response. - """ - try: - # Fetch current week and next few days for immediate display - now = datetime.now(pytz.utc) - immediate_events = [] - - start_date = now - timedelta(days=self.schedule_lookback_days) - end_date = now + timedelta(days=self.schedule_lookahead_days) - date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - response = self.session.get( - url, - params={"dates": date_str, "limit": 1000}, - headers=self.headers, - timeout=10, - ) - response.raise_for_status() - data = response.json() - immediate_events = data.get("events", []) - - if immediate_events: - self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") - return {"events": immediate_events} - - except requests.exceptions.RequestException as e: - self.logger.warning( - f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}" - ) - return None - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - pass - - def cleanup(self): - """Clean up resources when plugin is unloaded.""" - # Close HTTP session - if hasattr(self, 'session') and self.session: - try: - self.session.close() - except Exception as e: - self.logger.warning(f"Error closing session: {e}") - - # Clear caches - if hasattr(self, '_logo_cache'): - self._logo_cache.clear() - - self.logger.info(f"{self.__class__.__name__} cleanup completed") - def _is_favorite_game(self, game: Dict) -> bool: """Does either side of this game belong to a favourite team?""" @@ -2209,64 +1547,6 @@ def _load_division_team_ids(self) -> Dict[str, set]: self._division_team_ids[name] = ids return self._division_team_ids - def _game_divisions(self, game: Dict) -> Optional[set]: - """Divisions of BOTH sides, or None when they cannot be told. - - Both sides are collected, but the caller only needs ONE of them to sit - in a checked division. Requiring every participant read as "FBS games - only" and removed a ranked side hosting an FCS school -- which is still - a game involving a team the viewer checked the box for, and on a real - Week 2 slate it silently dropped five of the twenty ranked matchups. - What the checkbox is for is keeping FCS-versus-FCS out of a board - configured for FBS, and that still holds: a game with no checked - division on either side is dropped. - """ - divisions = self._load_division_team_ids() - if not any(divisions.values()): - return None - try: - ids = [int(game.get("home_id")), int(game.get("away_id"))] - except (TypeError, ValueError): - return None - present = set() - for team_id in ids: - for name in ("fbs", "fcs"): - if team_id in divisions.get(name, set()): - present.add(name) - break - else: - present.add("other") - return present - - def _league_has_rankings(self) -> bool: - """Only college leagues publish a poll; everyone else 404s. - - This gate matters more than it looks. _fetch_team_rankings only - short-circuits when the cache is non-empty, so a failed fetch leaves it - empty and the next update tries again -- at a 30s interval that is - ~2,900 pointless requests a day, per league, all of them 404s. - """ - league = (self.league or "").lower() - return "college" in league or "ncaa" in league - - @staticmethod - def _normalise_divisions(raw) -> List[str]: - """Division names from config, in the shape the filter expects. - - A hand-edited config can hold "fbs" where the schema says ["fbs"], and - list("fbs") is ['f', 'b', 's'] -- three names that match no division, so - every non-favourite game is rejected by a setting the user believes says - the opposite. An empty list is left empty: that means "no division - filter" and is a legitimate choice, not a mistake to correct. - """ - if isinstance(raw, str): - raw = [raw] - try: - items = list(raw or []) - except TypeError: - return [] - return [str(d).strip().lower() for d in items if str(d).strip()] - def _setting_int(self, key: str, default: int, low: int, high: int) -> int: """A count from config, clamped to the range its schema declares. @@ -2304,64 +1584,6 @@ def _best_rank(self, game: Dict) -> int: rankings.get(game.get("away_abbr"), 0)) if r] return min(ranked) if ranked else 99 - def _round_robin_favorites(self, games: List[Dict], limit: int) -> List[Dict]: - """Each favourite team's next game before any team's second one. - - Taking the soonest N favourite games spends the slots on whoever plays - most often. Walked across a real season with two favourites and a limit - of 2, nine days of it showed Auburn twice and Georgia not at all -- - Auburn played either side of a Georgia bye, so both slots went to - Auburn. The other-games pool already refuses to do this; favourites - were still doing it. - - Depth is kept where there is room: one favourite with three slots still - gets its next three games, because the round-robin only comes back for - a team's second game once every team has had a first. - - A game between two favourites is picked once and counts for both. - """ - if limit <= 0 or not games: - return [] - wanted = [t for t in (self.favorite_teams or []) if t] - if len(wanted) < 2: - return games[:limit] # nothing to share the slots between - - # Which side of a game belongs to which favourite is a per-lineage - # question: NRL matches on ESPN team IDs because its abbreviations are - # not unique ("NEW" is both Newcastle and New Zealand), while the rest - # match on abbreviation. Ask for the lineage's own matcher rather than - # assuming, or this silently groups nothing and every slot goes empty. - team_in = getattr(self, "_team_in", None) - if callable(team_in): - def belongs(game, team): - return bool(team_in(game.get("home_id"), [team]) - or team_in(game.get("away_id"), [team])) - else: - def belongs(game, team): - return team in (game.get("home_abbr"), game.get("away_abbr")) - - queues = {team: [] for team in wanted} - for game in games: # already in kickoff order - for team in wanted: - if belongs(game, team): - queues[team].append(game) - - picked, taken = [], set() - while len(picked) < limit: - progressed = False - for team in wanted: - queue = queues[team] - while queue and queue[0].get("id") in taken: - queue.pop(0) - if queue and len(picked) < limit: - game = queue.pop(0) - taken.add(game.get("id")) - picked.append(game) - progressed = True - if not progressed: - break # every queue is empty - return picked - def _by_importance(self, games: List[Dict], newest_first: bool = False) -> List[Dict]: """Non-favourite games, best matchup first. @@ -2426,35 +1648,6 @@ def key(game): #: migrates to "ranked" -- see _normalise_quality. _QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"}) - def _normalise_quality(self, raw) -> str: - """other_games_min_quality, as one of the values the code implements. - - An unusable value used to fall through every branch of - _passes_other_filters and silently mean "any" -- a quality bar the - board believes it has and does not. - """ - value = str(raw or "").strip().lower() - if value in self._QUALITY_CHOICES: - return value - if value == "broadcast": - # Retired in football-scoreboard 3.0.0 and now here. Measured - # against a real Week 1 and Week 2 college slate it passed 174 of - # 175 games: ESPN publishes a broadcaster for nearly everything - # now, ESPN+ included, so the tier read as a quality bar and - # behaved as "any". Boards holding it get the bar they thought - # they were getting. - self.logger.warning( - "%s: other_games_min_quality 'broadcast' has been retired -- " - "it let through nearly every game -- using 'ranked'. Change " - "the setting to clear this.", getattr(self, "sport_key", "?"), - ) - return "ranked" - self.logger.warning( - "%s: ignoring unusable other_games_min_quality=%r, using 'ranked'", - getattr(self, "sport_key", "?"), raw, - ) - return "ranked" - def _passes_other_filters(self, game: Dict) -> bool: """Is this non-favourite game worth one of the remaining slots? @@ -2494,44 +1687,6 @@ def _filtered_or_all(self, games: List[Dict]) -> List[Dict]: return kept or games - def _check_ranking_coverage(self, games: List[Dict]) -> None: - """Say so when a loaded poll matches nothing on the schedule. - - The table is keyed by the abbreviation the RANKINGS endpoint returns and - matched against the one the SCOREBOARD endpoint returns. Nothing - guarantees the two agree, and if they ever stop agreeing the filter - quietly removes every non-favourite game -- no exception, no log line, - just a shorter board. That is the same shape as the bug where rankings - were never loading at all, which survived until someone went looking. - - Throttled to once an hour: selection runs on every update. - """ - if self.other_games_min_quality != "ranked": - return - rankings = getattr(self, "_team_rankings_cache", None) or {} - if not rankings or not games: - return - if any(self._is_ranked_game(g) for g in games): - return - now = time.monotonic() - # Zero means never logged, not "logged at the epoch". monotonic() counts - # from an arbitrary origin -- on a freshly booted board it is a few - # hundred seconds -- so comparing against 0 swallowed the first warning - # for the first hour of uptime, which is exactly when a misconfigured - # board is being watched. CI caught this; a machine with days of uptime - # cannot. - if (self._ranking_coverage_logged_at - and now - self._ranking_coverage_logged_at < self._RANKING_COVERAGE_SECONDS): - return - self._ranking_coverage_logged_at = now - self.logger.warning( - "%s: %d ranked teams loaded, but none of the %d other games match " - "one -- the quality filter is removing every non-favourite game. " - "Ranked abbreviations look like: %s", - self.league, len(rankings), len(games), - ", ".join(sorted(rankings)[:8]), - ) - def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: """A rotating slice of the non-favourite games. @@ -2570,98 +1725,6 @@ def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: window += others[:limit - len(window)] return window - def _favorites_first( - self, - processed_games: List[Dict], - favorite_limit: int, - other_limit: int, - newest_first: bool = False, - ) -> List[Dict]: - """Favourite games first, then a bounded number of everything else. - - This is the middle setting the plugin was missing. `show_favorite_teams_only` - used to be the whole story: on, and you saw nothing but your teams; off, - and your teams were ignored entirely -- the selection just took the next - N games league-wide, so a UGA fan with 946 upcoming college games in the - window saw UGA about as often as chance allowed. - - Both counts are TOTALS here, not per-team. In favourites-only mode - `upcoming_games_to_show` is a per-team budget, which is reasonable when - the list is your own teams; applied to a dynamic group it is not. With - AP_TOP_10 resolving to a dozen teams, three games each is 28 distinct - cards before a single non-favourite is added. A total keeps the rotation - the length the user asked for. - """ - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key, reverse=True) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key) - - favorites, others, unfiltered = [], [], [] - for game in ordered: - if self._is_favorite_game(game): - favorites.append(game) # never filtered: your team is your team - continue - unfiltered.append(game) - if self._passes_other_filters(game): - others.append(game) - self._check_ranking_coverage(unfiltered) - - self._selection_pools = { - "favorites": favorites, - "others": self._by_importance(others, newest_first), - "unfiltered": self._by_importance(unfiltered, newest_first), - "favorite_limit": favorite_limit, - "other_limit": other_limit, - "newest_first": newest_first, - } - return self._compose_selection() - - def _compose_selection(self) -> List[Dict]: - """Favourites plus the current slice of others, in schedule order. - - Split out of _favorites_first so the slice can be re-cut between - fetches. The pools are settled -- which games exist, and which of them - are worth a slot -- while WHICH of the others is on screen is a display - decision, and gating it on the fetch made the rotation interval a lie: - update() returns early until upcoming_update_interval has passed, so a - four-minute rotation actually stepped fifteen windows once an hour. - Same lesson as _advance_live_game_if_due further down this file. - """ - pools = self._selection_pools - favorites, others = pools["favorites"], pools["others"] - favorite_limit, other_limit = pools["favorite_limit"], pools["other_limit"] - newest_first = pools["newest_first"] - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - - selected = self._round_robin_favorites(favorites, max(0, favorite_limit)) - selected.extend(self._other_games_window(others, max(0, other_limit))) - if not selected and other_limit > 0: - # Nothing survived at all: your teams are not playing inside the - # schedule window AND the filters removed every other game. Each - # check fails open on missing data, but a filter working exactly as - # asked can still match nothing on a given day, and with no - # favourite game left there is nothing to carry the mode -- an empty - # list is a blank panel, not a short one. Same whole-list fallback - # `_filtered_or_all` makes for a board with no favourites at all. - # `other_limit` of 0 is an explicit "favourites only", so that one - # is left to go quiet as asked. - selected = self._other_games_window(pools["unfiltered"], max(0, other_limit)) - # Re-sort so the card order still reads as a schedule. Selection decides - # WHICH games; it should not reorder them into favourites-then-others, - # which would show next week's UGA game before tonight's. - selected.sort(key=key, reverse=newest_first) - return selected - def _rotate_other_games_on_display(self) -> bool: """Swap in a freshly cut slice when the rotation interval has passed. @@ -2746,8 +1809,6 @@ def __init__( self.game_display_duration = self.mode_config.get("upcoming_game_duration", 15) - - def _select_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] ) -> List[Dict]: @@ -3271,39 +2332,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsRecent(SportsCore): - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mode_config.get( - "recent_update_interval", 3600 - ) # Check for recent games every hour - self.last_game_switch = 0 - self.game_display_duration = self.mode_config.get("recent_game_duration", 15) - self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 - - def _get_zero_clock_duration(self, game_id: str) -> float: - """Track how long a game has been at 0:00 clock.""" - current_time = time.time() - if game_id not in self._zero_clock_timestamps: - self._zero_clock_timestamps[game_id] = current_time - return 0.0 - return current_time - self._zero_clock_timestamps[game_id] - - def _clear_zero_clock_tracking(self, game_id: str) -> None: - """Clear tracking when game clock moves away from 0:00 or game ends.""" - if game_id in self._zero_clock_timestamps: - del self._zero_clock_timestamps[game_id] +class SportsRecent(SportsRecentSharedMixin, SportsCore): def _select_recent_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] @@ -3876,7 +2905,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -4035,76 +3064,6 @@ def _build_weighted_schedule(self, games: List[Dict]) -> List[str]: current_weights[best] -= total_weight return schedule - def _detect_stale_games(self, games: List[Dict]) -> None: - """Remove games that appear stale or haven't updated.""" - current_time = time.time() - - for game in games[:]: # Copy list to iterate safely - game_id = game.get("id") - if not game_id: - continue - - # Check if game data is stale - timestamps = self.game_update_timestamps.get(game_id, {}) - last_seen = timestamps.get("last_seen", 0) - - if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: - self.logger.warning( - f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(last seen {int(current_time - last_seen)}s ago)" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - continue - - # Also check if game appears to be over - if self._is_game_really_over(game): - self.logger.debug( - f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - - def _idle_live_interval(self) -> int: - """How long to wait before looking for live games again, when there are none. - - Escalates the longer nothing turns up, and any live game resets it, so - an in-season gap between games costs at most one escalated wait while - an out-of-season league stops polling on a live cadence entirely. - - Capped rather than unbounded: the cost of backing off is how late the - first game after a quiet spell is noticed, and past the cap the saving - stops being worth that. - """ - streak = getattr(self, "_empty_live_streak", 0) - base = self.no_data_interval - ceiling = getattr(self, "live_idle_max_interval", - _DEFAULT_LIVE_IDLE_MAX_SECONDS) - # The ceiling bounds the un-escalated interval too. The two settings are - # independent integers with no cross-validation, so base > ceiling is a - # reachable config -- and returning base unclamped there made the wait - # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), - # the opposite of what the setting named "maximum" promises. - if streak >= _IDLE_LONG_STREAK: - return min(int(base * _IDLE_LONG_FACTOR), ceiling) - if streak >= _IDLE_SHORT_STREAK: - return min(int(base * _IDLE_SHORT_FACTOR), ceiling) - return min(base, ceiling) - - def _note_live_fetch(self, found_live: bool) -> None: - """Record whether a look for live games found any.""" - if found_live: - if getattr(self, "_empty_live_streak", 0): - self.logger.info( - "Live games found after %d empty check(s); back to the " - "live update interval", self._empty_live_streak) - self._empty_live_streak = 0 - else: - self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 - def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 67eb1e0b..d326a9d3 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.26.1", + "version": "1.28.0", "update_interval": 60, "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", @@ -19,6 +19,13 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.28.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "The sports.py logic every scoreboard shares moves to the core. Forty-five method bodies here were byte-identical to the same forty-five in every other scoreboard -- the selection and rotation engine, the font, colour and date subsystem, and the switch-mode upcoming card -- so they now come from src.common.sports_shared and this plugin inherits them. 1040 lines removed here. A fix to any of that now reaches every scoreboard at once instead of needing eight identical edits. Three deliberately stayed behind: _get_timezone, because it binds a per-plugin timezone module whose contents differ, and the two abstract stubs that define what makes this sport its own. Nothing drawn changes -- the bodies moved rather than being rewritten, and all 176 safety-harness renders across the eight plugins are byte-identical to before. The floor rises to 3.3.0, the release that first ships the shared module.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.26.1", "released": "2026-09-02", @@ -289,7 +296,7 @@ { "released": "2026-07-02", "version": "1.6.0", - "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores \u2014 spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league.", + "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores — spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league.", "ledmatrix_min": "2.0.0" }, { @@ -376,6 +383,6 @@ "entry_point": "manager.py", "class_name": "BasketballScoreboardPlugin", "compatible_versions": [ - ">=3.2.0" + ">=3.3.0" ] } diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 1d9e8b17..1c133e91 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -41,6 +41,8 @@ if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) from src.logo_downloader import LogoDownloader, download_missing_logo +from src.common.sports_shared import ( + SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin) def _resolve_font_path(path: str) -> str: @@ -86,7 +88,6 @@ def _resolve_font_path(path: str) -> str: return path - _DEFAULT_LOOKBACK_DAYS = 14 _DEFAULT_LOOKAHEAD_DAYS = 7 _MIN_WINDOW_DAYS = 1 @@ -155,7 +156,7 @@ def _logo_needs_refresh(logo_file) -> bool: return False -class SportsCore(ABC): +class SportsCore(SportsCoreSharedMixin, ABC): TOURNAMENT_ROUND_ORDER: ClassVar[Dict[str, int]] = {"NCG": 0, "F4": 1, "E8": 2, "S16": 3, "R32": 4, "R64": 5, "": 6} def __init__( @@ -366,24 +367,6 @@ def __init__( "Background service not available - using synchronous fetching" ) - def _get_season_schedule_dates(self) -> tuple[str, str]: - return "", "" - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Placeholder draw method - subclasses should override.""" - # This base method will be simple, subclasses provide specifics - try: - img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - status = game.get("status_text", "N/A") - self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) - self.display_manager.image.paste(img, (0, 0)) - # Don't call update_display here, let subclasses handle it after drawing - except Exception as e: - self.logger.error( - f"Error in base _draw_scorebug_layout: {e}", exc_info=True - ) - def display(self, force_clear: bool = False) -> bool: """Render the current game. Returns False when nothing can be shown.""" if not self.is_enabled: # Check if module is enabled @@ -431,62 +414,6 @@ def display(self, force_clear: bool = False) -> bool: 'four_by_six': '4x6-font.ttf', } - @classmethod - def _crisp_size(cls, font_file, desired): - """Snap *desired* to the nearest size *font_file* renders crisply at. - - A face with no known grid is returned unchanged, so a user-supplied - font is never second-guessed. - """ - font_file = cls._FONT_NAME_ALIASES.get(font_file, font_file) - grid = cls._FONT_PIXEL_GRID.get(font_file) - if not grid or not desired or desired <= 0: - return desired - return max(grid, int(round(float(desired) / grid)) * grid) - - def _schema_font_size(self, element_key): - """The font_size this plugin's config_schema.json declares, or None.""" - if not element_key: - return None - cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None) - if cache is None: - cache = {} - try: - import json - schema_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'config_schema.json') - with open(schema_path) as fh: - schema = json.load(fh) - props = (schema.get('properties', {}) - .get('customization', {}) - .get('properties', {})) - for key, spec in props.items(): - size = spec.get('properties', {}).get('font_size', {}).get('default') - if size is not None: - cache[key] = int(size) - except Exception: - cache = {} - self.__class__._SCHEMA_FONT_SIZES = cache - return cache.get(element_key) - - def _resolve_font_size(self, element_config, element_key, default_size, font_name): - """Size to render at: the user's choice, or a grid-snapped default. - - A configured size counts as a real choice only when it differs from - the schema default. The web UI writes the whole schema default block - on every save, so "font_size == schema default" carries no intent and - would otherwise pin every install to an anti-aliased size forever. - """ - configured = (element_config or {}).get('font_size') - if configured is not None: - try: - configured = int(configured) - if configured != self._schema_font_size(element_key): - return configured - except (TypeError, ValueError): - pass - return self._crisp_size(font_name, default_size) - def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], default_size: int = 8, default_font: str = 'PressStart2P-Regular.ttf', element_key=None) -> ImageFont.FreeTypeFont: """ Load a custom font from an element configuration dictionary. @@ -567,86 +494,6 @@ def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], "tie": (255, 200, 0), } - @staticmethod - def _coerce_rgb(value, fallback): - """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" - # Checked before unpacking: a 3-character string ("123") would otherwise - # iterate into three digits and yield a colour rather than the fallback. - if not isinstance(value, (list, tuple)) or len(value) != 3: - return fallback - try: - r, g, b = (max(0, min(255, int(channel))) for channel in value) - except (TypeError, ValueError): - return fallback - return (r, g, b) - - @staticmethod - def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: - """Is the home/away side of this game a favorite team? - - Both the abbreviation and the ESPN id are checked, because a couple of - leagues (NRL) match favorites by id where abbreviations collide. - """ - for key in (f"{side}_abbr", f"{side}_id"): - value = game.get(key) - if value is not None and str(value).strip().upper() in favorites: - return True - return False - - def _favorite_result(self, game: Dict) -> Optional[str]: - """Say how the favorite team did in a finished game. - - Returns 'win', 'loss' or 'tie', or None when there is no single team - to root for: no favorites configured, neither side is a favorite, or - *both* are -- a favorite-vs-favorite game has no losing side worth - flagging in red. Also None when the scores are not usable numbers. - """ - favorites = getattr(self, "favorite_teams", None) or [] - favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} - if not favorites: - return None - - home_fav = self._side_is_favorite(game, "home", favorites) - away_fav = self._side_is_favorite(game, "away", favorites) - if home_fav == away_fav: - return None - - try: - # int(float(...)) to match GameRenderer._side_score exactly -- the - # two paths must agree on what counts as a usable score. - home_score = int(float(str(game.get("home_score", "")).strip())) - away_score = int(float(str(game.get("away_score", "")).strip())) - except (TypeError, ValueError): - return None - - if home_score == away_score: - return "tie" - favorite_score, other_score = ( - (home_score, away_score) if home_fav else (away_score, home_score) - ) - return "win" if favorite_score > other_score else "loss" - - def _recent_score_color(self, game: Dict, default): - """Fill color for a finished game's score, per favorite_result_colors.""" - try: - settings = (self.config.get("customization") or {}).get( - "favorite_result_colors" - ) or {} - if not settings.get("enabled", False): - return default - result = self._favorite_result(game) - if result is None: - return default - return self._coerce_rgb( - settings.get(f"{result}_color"), - self.FAVORITE_RESULT_COLOR_DEFAULTS[result], - ) - except Exception: - self.logger.debug( - "Could not resolve favorite result color", exc_info=True - ) - return default - #: How far each logo is shifted outward, off the panel edge, by the #: scorebug layouts (they paste at -2 and width - logo_width + 2). Kept #: here because the logo sizing has to know it. @@ -794,137 +641,6 @@ def _fit_score_font(self, fonts): #: -- so on a taller panel they grew and the score did not. _FONT_DESIGN_HEIGHT: ClassVar[int] = 32 - def _score_font_size(self) -> int: - """Pixel size the score is currently drawn at.""" - return getattr(self.fonts.get("score"), "size", 8) or 8 - - def _time_font_size(self) -> int: - """Pixel size the clock/date face is currently drawn at.""" - return getattr(self.fonts.get("time"), "size", 8) or 8 - - def _user_chose_size(self, element_key: str) -> bool: - """True when customization..font_size is a real choice. - - The web UI's save flow writes the whole schema default block into - config.json on every save, whether or not the user touched that - section, so a size merely being PRESENT carries no intent. Only one - that differs from the schema default does. - """ - element = (self.config.get('customization', {}) or {}).get(element_key) or {} - configured = element.get('font_size') - if configured is None: - return False - try: - return int(configured) != self._schema_font_size(element_key) - except (TypeError, ValueError): - return False - - def _grid_scaled_size(self, font): - """(path, grid, size) for *font* regrown to this panel's height. - - None when the panel is at or below the design height (nothing to do), - or when the face has no known pixel grid -- a user-supplied font is - never second-guessed, because we do not know what it renders crisply - at. - """ - path = getattr(font, 'path', None) - base = getattr(font, 'size', None) - if not base or not isinstance(path, str): - return None - face = os.path.basename(path) - grid = self._FONT_PIXEL_GRID.get(self._FONT_NAME_ALIASES.get(face, face)) - if not grid: - return None - scale = float(self.display_height) / (self._FONT_DESIGN_HEIGHT or 32) - if scale <= 1.0: - return None - return path, grid, max(int(base), int(self._crisp_size(face, base * scale))) - - def _scale_headline_fonts(self, fonts): - """Grow the score with the panel, and hold the clock/date below it. - - The score is the one number the card exists to show, and it was the - only element not sized from the panel. Worse, it was not even bigger - than its neighbours: PressStart2P renders crisply on an 8px grid, so - the 10px default snapped to 8 -- the same 8 the period/clock above it - and the game date below it are drawn at. Three lines of identical - type, none of them the headline, which is what makes the score read as - lower priority than the time and the date rather than the point of the - card. - - So the score is sized from display_height and snapped to its face's - pixel grid (off the grid FreeType anti-aliases the strokes, and on an - LED matrix a part-lit pixel is a dim lamp rather than a soft edge), - then stepped back down that grid until it fits its share of the width. - The clock/date face is regrown the same way but held at least one grid - step below the score, so the ranking between them is visible rather - than implied. - - A 32-tall panel scales by exactly 1.0 and is left byte-identical; a - size the user set explicitly is never overridden. - """ - self._score_grew = False - if not self._DRAWS_SCORE: - # No score on this screen, so none of the sizing below is for it. - return fonts - try: - scaled = None if self._user_chose_size('score_text') else \ - self._grid_scaled_size(fonts.get('score')) - if scaled is not None: - path, grid, size = scaled - base = getattr(fonts['score'], 'size', size) or size - size = min(size, base * self._SCORE_MAX_GROWTH) - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - # Measured from a fixed five-character score rather than the - # live one, so the card does not resize when a side passes 9. - while size > grid: - if probe.textlength( - self._SCORE_PROBE_TEXT, - font=ImageFont.truetype(path, size)) <= budget: - break - size -= grid - if size != getattr(fonts['score'], 'size', size): - fonts['score'] = ImageFont.truetype(path, size) - self._score_grew = True - - if not self._score_grew and not self._user_chose_size('score_text') \ - and self.display_height > self._FONT_DESIGN_HEIGHT: - # PressStart2P could not grow inside the budget -- its next crisp - # size is simply too wide for this panel. A narrower face still - # can: 4x6-font at 14px is nearly as tall as PressStart2P at 16 - # and about half as wide. This matters beyond the score itself, - # because a card whose score never grows never reserves the - # centre either, so its logos stay at the uncapped 1.5x and are - # drawn straight over the score -- which is what a three-digit - # basketball score does on a 128x64 board. - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - current = getattr(fonts.get('score'), 'size', 0) or 0 - for _name, _size in self._NARROW_SCORE_RUNGS: - if _size <= current: - continue - _path = _resolve_font_path(f"assets/fonts/{_name}") - _candidate = ImageFont.truetype(_path, _size) - if probe.textlength(self._SCORE_PROBE_TEXT, - font=_candidate) <= budget: - fonts['score'] = _candidate - self._score_grew = True - break - - scaled = None if self._user_chose_size('period_text') else \ - self._grid_scaled_size(fonts.get('time')) - if scaled is not None: - path, grid, size = scaled - ceiling = getattr(fonts.get('score'), 'size', 0) or 0 - if ceiling and size >= ceiling: - size = max(grid, ceiling - grid) - if size != getattr(fonts['time'], 'size', size): - fonts['time'] = ImageFont.truetype(path, size) - except Exception: - self.logger.debug("Headline font scaling skipped", exc_info=True) - return fonts - def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1009,124 +725,6 @@ def _load_fonts(self): "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", ) - def _card_option(self, key: str, default: Any = None) -> Any: - """Read one key from the scroll_card config block.""" - block = (self.config or {}).get("scroll_card") - if isinstance(block, dict) and block.get(key) is not None: - return block.get(key) - return default - - def _switch_upcoming_center(self) -> str: - """Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'.""" - mode = str(self._card_option("switch_upcoming_center", "date_time") - or "date_time").lower() - if mode == "inherit": - mode = str(self._card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time", "none") else "date_time" - - def _vs_text(self) -> str: - """Separator drawn between the teams -- "VS", "@", "at", anything.""" - return str(self._card_option("vs_text", "VS")) - - def _switch_date_format(self) -> str: - """Date style for the full-screen scorebug. - - Its own key rather than the shared ``date_format`` because the two - displays disagree about the default: the scroll card renders "Sep 19" - while _extract_game_details_common emits "9/19", the "numeric" style, - and this scorebug has always drawn it. Reading the shared key here - would restyle every existing panel on update -- and "leave it alone - when unset" is not available, because the core merges schema defaults - into the config on every load, so the key is never actually unset. - "inherit" opts into the scroll and Vegas setting. - """ - fmt = str(self._card_option("switch_date_format", "numeric") or "numeric").lower() - if fmt == "inherit": - fmt = str(self._card_option("date_format", "abbrev") or "abbrev").lower() - return fmt - - def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: - """Format an upcoming date per scroll_card.switch_date_format.""" - raw = str(date_text or "").strip() - if not raw: - return raw - fmt = self._switch_date_format() - if fmt == "numeric": - return raw - parts = raw.replace("-", "/").split("/") - if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): - return raw - month, day = int(parts[0]), int(parts[1]) - if not 1 <= month <= 12: - return raw - name = self._MONTH_ABBR[month - 1] - if fmt == "numeric_day_first": - return f"{day}/{month}" - if fmt == "day_first": - return f"{day} {name}" - if fmt == "weekday": - weekday = self._weekday_for(game) - return f"{weekday} {name} {day}" if weekday else f"{name} {day}" - return f"{name} {day}" - - def _weekday_for(self, game: Optional[Dict]) -> str: - """Weekday abbreviation from the game's start time, or ''.""" - if not game: - return "" - raw = game.get("start_time_utc") or game.get("start_time") - if not raw: - return "" - try: - start = raw if isinstance(raw, datetime) else datetime.fromisoformat( - str(raw).replace("Z", "+00:00")) - return self._WEEKDAY_ABBR[start.astimezone(self._get_timezone()).weekday()] - except (ValueError, TypeError, OverflowError): - return "" - - def _format_game_time(self, time_text: str) -> str: - """Return the time as-is (12h) or converted to 24h.""" - raw = str(time_text or "").strip() - if not raw or str(self._card_option("time_format", "12h")) != "24h": - return raw - cleaned = raw.upper().replace(" ", "") - meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" - if not meridiem: - return raw - try: - hh, _, mm = cleaned[:-2].partition(":") - hour, minute = int(hh), int(mm or 0) - except ValueError: - return raw - if not (0 <= hour <= 12 and 0 <= minute <= 59): - return raw - hour = hour % 12 + (12 if meridiem == "PM" else 0) - return f"{hour:02d}:{minute:02d}" - - def _scorebug_font(self, draw, text: str, width: int): - """The face this scorebug draws its date and time in. - - Always the "time" face, which is what this display has used for both - rows for as long as it has existed: changing switch_upcoming_center - moves the two lines around, it is not meant to restyle them, so the - type stays put while the placement changes. - - The single exception is text that cannot fit the panel at all. Only - the "weekday" date can do that -- "Fri Sep 19" measures 80px in an - 8px face, on a board 64px wide -- and the smaller "detail" face is a - better answer there than running off both edges. Every other date and - time this display can produce fits, so in practice the face never - changes; it is a floor, not a style rule. - """ - font = self.fonts["time"] - if not text: - return font - try: - if draw.textlength(text, font=font) + 2 <= width: - return font - except (TypeError, ValueError): - return font - return self.fonts.get("detail") or font - def _upcoming_date_and_time_text(self, game_date: str, game_time: str, game: Optional[Dict] = None) -> Tuple[str, str]: """The formatted (date, time) pair, blanked by show_date/show_time.""" @@ -1136,106 +734,6 @@ def _upcoming_date_and_time_text(self, game_date: str, game_time: str, if self._card_option("show_time", True) else "") return date_text, time_text - def _draw_upcoming_center_switch(self, draw, game: Dict, center_y: int, - game_date: str, game_time: str, - display_width: Optional[int] = None, - display_height: Optional[int] = None, - date_element: str = 'date', - time_element: str = 'time', - second_row_y_offset: bool = True) -> bool: - """Draw the middle of the full-screen upcoming scorebug. - - Returns True when the header above it ("Next Game", or the league - name) should still be drawn. In "vs" and "none" the date and time move - out of the middle and into the top and bottom slots, mirroring the - scroll card -- and the top slot is where the header used to be, so the - caller drops it. - - ``date_element``/``time_element``/``second_row_y_offset`` exist only so - the layout-offset keys stay exactly what each plugin's schema - advertises; this sport's defaults are the common case. - """ - width = self.display_width if display_width is None else display_width - height = self.display_height if display_height is None else display_height - mode = self._switch_upcoming_center() - date_text, time_text = self._upcoming_date_and_time_text( - game_date, game_time, game) - swapped = bool(self._card_option("swap_date_time", False)) - - if mode == "date_time": - # Historically the date sat at center_y - 7 with the time 9px - # under it, and the time's row was derived from the date's, so a - # date y_offset moved the pair. Both still hold; the slots only - # trade places when swap_date_time is set, and hiding one line - # leaves the other where it was rather than re-centering the stack. - slots = [(time_element, time_text), (date_element, date_text)] if swapped \ - else [(date_element, date_text), (time_element, time_text)] - row_y = center_y - 7 - for index, (element, text) in enumerate(slots): - if index: - row_y += 9 - if second_row_y_offset: - row_y += self._get_layout_offset(element, 'y_offset') - else: - row_y += self._get_layout_offset(element, 'y_offset') - if not text: - continue - font = self._scorebug_font(draw, text, width) - text_width = draw.textlength(text, font=font) - text_x = ((width - text_width) // 2 - + self._get_layout_offset(element, 'x_offset')) - self._draw_text_with_outline( - draw, text, (text_x, row_y), font - ) - return True - - if mode == "vs": - vs_text = self._vs_text() - if vs_text: - vs_width = draw.textlength(vs_text, font=self.fonts["score"]) - vs_x = ((width - vs_width) // 2 - + self._get_layout_offset('score', 'x_offset')) - vs_y = (center_y - 3 - + self._get_layout_offset('score', 'y_offset')) - self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts["score"] - ) - - # "vs" and "none" both push the date and time out to the edges, time - # on top unless swap_date_time says otherwise -- the same order the - # scroll card uses. - if swapped: - top_element, top_text = date_element, date_text - bottom_element, bottom_text = time_element, time_text - else: - top_element, top_text = time_element, time_text - bottom_element, bottom_text = date_element, date_text - - if top_text: - top_font = self._scorebug_font(draw, top_text, width) - top_width = draw.textlength(top_text, font=top_font) - top_x = ((width - top_width) // 2 - + self._get_layout_offset(top_element, 'x_offset')) - top_y = 1 + self._get_layout_offset(top_element, 'y_offset') - self._draw_text_with_outline( - draw, top_text, (top_x, top_y), top_font - ) - if bottom_text: - bottom_font = self._scorebug_font(draw, bottom_text, width) - bottom_width = draw.textlength(bottom_text, font=bottom_font) - bottom_x = ((width - bottom_width) // 2 - + self._get_layout_offset(bottom_element, 'x_offset')) - # Measured, not a fixed offset: the detail font is 6px in most - # plugins and 10px in soccer and nrl, where a fixed -7 ran the - # date off the panel. - ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] - bottom_y = (max(0, height - ink_bottom - 1) - + self._get_layout_offset(bottom_element, 'y_offset')) - self._draw_text_with_outline( - draw, bottom_text, (bottom_x, bottom_y), bottom_font - ) - return False - def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: """ Get layout offset for a specific element and axis. @@ -1409,105 +907,6 @@ def _draw_dynamic_odds( "rank": "rank_text", } - def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): - """Per-element text colour from customization..text_color.""" - try: - cfg = (self.config or {}).get("customization", {}).get(element, {}) - value = cfg.get("text_color") - if isinstance(value, (list, tuple)) and len(value) == 3: - return tuple(max(0, min(255, int(c))) for c in value) - if isinstance(value, str) and value.startswith("#") and len(value) == 7: - return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) - except (TypeError, ValueError): - pass - return default - - def _unshare_element_fonts(self, fonts): - """Give each colourable element its own face object. - - The colour a draw gets is resolved from the face it was handed, and - several of these loaders legitimately hand one object to more than one - element -- a size resolver that lands two elements on the same face, a - fallback that fills every key from one default, football's narrowing - step that deliberately shrinks the clock along with the score. Sharing - the object makes the element ambiguous and the colour unresolvable. - - Re-instantiating from the same path and size gives a distinct object - with identical metrics, so nothing about the rendering changes; only - the ability to tell two elements apart does. Faces that cannot be - rebuilt (a BDF loaded through freetype.Face, anything without a usable - path) are left shared, and their draws stay white as before. - """ - try: - from PIL import ImageFont as _IF - except ImportError: # pragma: no cover - return fonts - seen = {} - for key in self._ELEMENT_FOR_FONT: - font = fonts.get(key) - if font is None: - continue - if id(font) not in seen: - seen[id(font)] = key - continue - path, size = getattr(font, "path", None), getattr(font, "size", None) - if not path or not size: - continue - try: - fonts[key] = _IF.truetype(path, size) - except (OSError, ValueError, TypeError): - self.logger.debug( - "Could not un-share the %s face; it keeps the default colour", key) - return fonts - - def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): - """Colour for whichever element owns this face. - - Matched on identity, and deliberately gives up when one object is - shared: the last-resort font path can hand the same face to several - keys, and there is no right answer for which element's colour that is. - White is what those draws used before, so ambiguity costs nothing. - """ - try: - fonts = getattr(self, "fonts", None) or {} - matches = [element for key, element in self._ELEMENT_FOR_FONT.items() - if fonts.get(key) is font] - if len(matches) == 1: - return self._element_color(matches[0], default) - except (AttributeError, TypeError): - pass - return default - - def _draw_text_with_outline( - self, draw, text, position, font, fill=None, outline_color=(0, 0, 0) - ): - """Draw text with a black outline for better readability.""" - # Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get - # anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying - # glyphs. 1-bit mode keeps strokes crisp. - # Defaults to the configured colour for whichever element owns - # this face rather than to white, so customization..text_color - # reaches every draw. The schema has offered those pickers all along - # and they only ever changed the font. An explicit fill still wins: - # the odds colours and the favourite-result score tint mean something - # the palette does not. - if fill is None: - fill = self._font_color(font) - draw.fontmode = "1" - x, y = position - for dx, dy in [ - (-1, -1), - (-1, 0), - (-1, 1), - (0, -1), - (0, 1), - (1, -1), - (1, 0), - (1, 1), - ]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - def _load_and_resize_logo( self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]: @@ -1687,14 +1086,6 @@ def _get_timezone(self): log=self.logger, ) - def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: - """Check if we should log a warning based on cooldown period.""" - current_time = time.time() - if current_time - self._last_warning_time > cooldown: - self._last_warning_time = current_time - return True - return False - # Which ranking block the badge reads. ESPN answers /rankings with more # than one block for several leagues, and the FIRST is not always a poll: # men's and women's college hockey front "NCAA Men's/Women's Hockey @@ -2263,58 +1654,6 @@ def _fetch_todays_games(self) -> Optional[Dict]: ) return None - def _get_weeks_data(self) -> Optional[Dict]: - """ - Get partial data for immediate display while background fetch is in progress. - This fetches current/recent games only for quick response. - """ - try: - # Fetch current week and next few days for immediate display - now = datetime.now(pytz.utc) - immediate_events = [] - - start_date = now - timedelta(days=self.schedule_lookback_days) - end_date = now + timedelta(days=self.schedule_lookahead_days) - date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - response = self.session.get( - url, - params={"dates": date_str, "limit": 1000}, - headers=self.headers, - timeout=10, - ) - response.raise_for_status() - data = response.json() - immediate_events = data.get("events", []) - - if immediate_events: - self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") - return {"events": immediate_events} - - except requests.exceptions.RequestException as e: - self.logger.warning( - f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}" - ) - return None - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - pass - - def cleanup(self): - """Clean up resources when plugin is unloaded.""" - # Close HTTP session - if hasattr(self, 'session') and self.session: - try: - self.session.close() - except Exception as e: - self.logger.warning(f"Error closing session: {e}") - - # Clear caches - if hasattr(self, '_logo_cache'): - self._logo_cache.clear() - - self.logger.info(f"{self.__class__.__name__} cleanup completed") - def _is_favorite_game(self, game: Dict) -> bool: """Does either side of this game belong to a favourite team?""" @@ -2425,64 +1764,6 @@ def _load_division_team_ids(self) -> Dict[str, set]: self._division_team_ids[name] = ids return self._division_team_ids - def _game_divisions(self, game: Dict) -> Optional[set]: - """Divisions of BOTH sides, or None when they cannot be told. - - Both sides are collected, but the caller only needs ONE of them to sit - in a checked division. Requiring every participant read as "FBS games - only" and removed a ranked side hosting an FCS school -- which is still - a game involving a team the viewer checked the box for, and on a real - Week 2 slate it silently dropped five of the twenty ranked matchups. - What the checkbox is for is keeping FCS-versus-FCS out of a board - configured for FBS, and that still holds: a game with no checked - division on either side is dropped. - """ - divisions = self._load_division_team_ids() - if not any(divisions.values()): - return None - try: - ids = [int(game.get("home_id")), int(game.get("away_id"))] - except (TypeError, ValueError): - return None - present = set() - for team_id in ids: - for name in ("fbs", "fcs"): - if team_id in divisions.get(name, set()): - present.add(name) - break - else: - present.add("other") - return present - - def _league_has_rankings(self) -> bool: - """Only college leagues publish a poll; everyone else 404s. - - This gate matters more than it looks. _fetch_team_rankings only - short-circuits when the cache is non-empty, so a failed fetch leaves it - empty and the next update tries again -- at a 30s interval that is - ~2,900 pointless requests a day, per league, all of them 404s. - """ - league = (self.league or "").lower() - return "college" in league or "ncaa" in league - - @staticmethod - def _normalise_divisions(raw) -> List[str]: - """Division names from config, in the shape the filter expects. - - A hand-edited config can hold "fbs" where the schema says ["fbs"], and - list("fbs") is ['f', 'b', 's'] -- three names that match no division, so - every non-favourite game is rejected by a setting the user believes says - the opposite. An empty list is left empty: that means "no division - filter" and is a legitimate choice, not a mistake to correct. - """ - if isinstance(raw, str): - raw = [raw] - try: - items = list(raw or []) - except TypeError: - return [] - return [str(d).strip().lower() for d in items if str(d).strip()] - def _setting_int(self, key: str, default: int, low: int, high: int) -> int: """A count from config, clamped to the range its schema declares. @@ -2520,64 +1801,6 @@ def _best_rank(self, game: Dict) -> int: rankings.get(game.get("away_abbr"), 0)) if r] return min(ranked) if ranked else 99 - def _round_robin_favorites(self, games: List[Dict], limit: int) -> List[Dict]: - """Each favourite team's next game before any team's second one. - - Taking the soonest N favourite games spends the slots on whoever plays - most often. Walked across a real season with two favourites and a limit - of 2, nine days of it showed Auburn twice and Georgia not at all -- - Auburn played either side of a Georgia bye, so both slots went to - Auburn. The other-games pool already refuses to do this; favourites - were still doing it. - - Depth is kept where there is room: one favourite with three slots still - gets its next three games, because the round-robin only comes back for - a team's second game once every team has had a first. - - A game between two favourites is picked once and counts for both. - """ - if limit <= 0 or not games: - return [] - wanted = [t for t in (self.favorite_teams or []) if t] - if len(wanted) < 2: - return games[:limit] # nothing to share the slots between - - # Which side of a game belongs to which favourite is a per-lineage - # question: NRL matches on ESPN team IDs because its abbreviations are - # not unique ("NEW" is both Newcastle and New Zealand), while the rest - # match on abbreviation. Ask for the lineage's own matcher rather than - # assuming, or this silently groups nothing and every slot goes empty. - team_in = getattr(self, "_team_in", None) - if callable(team_in): - def belongs(game, team): - return bool(team_in(game.get("home_id"), [team]) - or team_in(game.get("away_id"), [team])) - else: - def belongs(game, team): - return team in (game.get("home_abbr"), game.get("away_abbr")) - - queues = {team: [] for team in wanted} - for game in games: # already in kickoff order - for team in wanted: - if belongs(game, team): - queues[team].append(game) - - picked, taken = [], set() - while len(picked) < limit: - progressed = False - for team in wanted: - queue = queues[team] - while queue and queue[0].get("id") in taken: - queue.pop(0) - if queue and len(picked) < limit: - game = queue.pop(0) - taken.add(game.get("id")) - picked.append(game) - progressed = True - if not progressed: - break # every queue is empty - return picked - def _by_importance(self, games: List[Dict], newest_first: bool = False) -> List[Dict]: """Non-favourite games, best matchup first. @@ -2642,35 +1865,6 @@ def key(game): #: migrates to "ranked" -- see _normalise_quality. _QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"}) - def _normalise_quality(self, raw) -> str: - """other_games_min_quality, as one of the values the code implements. - - An unusable value used to fall through every branch of - _passes_other_filters and silently mean "any" -- a quality bar the - board believes it has and does not. - """ - value = str(raw or "").strip().lower() - if value in self._QUALITY_CHOICES: - return value - if value == "broadcast": - # Retired in football-scoreboard 3.0.0 and now here. Measured - # against a real Week 1 and Week 2 college slate it passed 174 of - # 175 games: ESPN publishes a broadcaster for nearly everything - # now, ESPN+ included, so the tier read as a quality bar and - # behaved as "any". Boards holding it get the bar they thought - # they were getting. - self.logger.warning( - "%s: other_games_min_quality 'broadcast' has been retired -- " - "it let through nearly every game -- using 'ranked'. Change " - "the setting to clear this.", getattr(self, "sport_key", "?"), - ) - return "ranked" - self.logger.warning( - "%s: ignoring unusable other_games_min_quality=%r, using 'ranked'", - getattr(self, "sport_key", "?"), raw, - ) - return "ranked" - def _passes_other_filters(self, game: Dict) -> bool: """Is this non-favourite game worth one of the remaining slots? @@ -2710,44 +1904,6 @@ def _filtered_or_all(self, games: List[Dict]) -> List[Dict]: return kept or games - def _check_ranking_coverage(self, games: List[Dict]) -> None: - """Say so when a loaded poll matches nothing on the schedule. - - The table is keyed by the abbreviation the RANKINGS endpoint returns and - matched against the one the SCOREBOARD endpoint returns. Nothing - guarantees the two agree, and if they ever stop agreeing the filter - quietly removes every non-favourite game -- no exception, no log line, - just a shorter board. That is the same shape as the bug where rankings - were never loading at all, which survived until someone went looking. - - Throttled to once an hour: selection runs on every update. - """ - if self.other_games_min_quality != "ranked": - return - rankings = getattr(self, "_team_rankings_cache", None) or {} - if not rankings or not games: - return - if any(self._is_ranked_game(g) for g in games): - return - now = time.monotonic() - # Zero means never logged, not "logged at the epoch". monotonic() counts - # from an arbitrary origin -- on a freshly booted board it is a few - # hundred seconds -- so comparing against 0 swallowed the first warning - # for the first hour of uptime, which is exactly when a misconfigured - # board is being watched. CI caught this; a machine with days of uptime - # cannot. - if (self._ranking_coverage_logged_at - and now - self._ranking_coverage_logged_at < self._RANKING_COVERAGE_SECONDS): - return - self._ranking_coverage_logged_at = now - self.logger.warning( - "%s: %d ranked teams loaded, but none of the %d other games match " - "one -- the quality filter is removing every non-favourite game. " - "Ranked abbreviations look like: %s", - self.league, len(rankings), len(games), - ", ".join(sorted(rankings)[:8]), - ) - def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: """A rotating slice of the non-favourite games. @@ -2786,98 +1942,6 @@ def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: window += others[:limit - len(window)] return window - def _favorites_first( - self, - processed_games: List[Dict], - favorite_limit: int, - other_limit: int, - newest_first: bool = False, - ) -> List[Dict]: - """Favourite games first, then a bounded number of everything else. - - This is the middle setting the plugin was missing. `show_favorite_teams_only` - used to be the whole story: on, and you saw nothing but your teams; off, - and your teams were ignored entirely -- the selection just took the next - N games league-wide, so a UGA fan with 946 upcoming college games in the - window saw UGA about as often as chance allowed. - - Both counts are TOTALS here, not per-team. In favourites-only mode - `upcoming_games_to_show` is a per-team budget, which is reasonable when - the list is your own teams; applied to a dynamic group it is not. With - AP_TOP_10 resolving to a dozen teams, three games each is 28 distinct - cards before a single non-favourite is added. A total keeps the rotation - the length the user asked for. - """ - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key, reverse=True) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key) - - favorites, others, unfiltered = [], [], [] - for game in ordered: - if self._is_favorite_game(game): - favorites.append(game) # never filtered: your team is your team - continue - unfiltered.append(game) - if self._passes_other_filters(game): - others.append(game) - self._check_ranking_coverage(unfiltered) - - self._selection_pools = { - "favorites": favorites, - "others": self._by_importance(others, newest_first), - "unfiltered": self._by_importance(unfiltered, newest_first), - "favorite_limit": favorite_limit, - "other_limit": other_limit, - "newest_first": newest_first, - } - return self._compose_selection() - - def _compose_selection(self) -> List[Dict]: - """Favourites plus the current slice of others, in schedule order. - - Split out of _favorites_first so the slice can be re-cut between - fetches. The pools are settled -- which games exist, and which of them - are worth a slot -- while WHICH of the others is on screen is a display - decision, and gating it on the fetch made the rotation interval a lie: - update() returns early until upcoming_update_interval has passed, so a - four-minute rotation actually stepped fifteen windows once an hour. - Same lesson as _advance_live_game_if_due further down this file. - """ - pools = self._selection_pools - favorites, others = pools["favorites"], pools["others"] - favorite_limit, other_limit = pools["favorite_limit"], pools["other_limit"] - newest_first = pools["newest_first"] - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - - selected = self._round_robin_favorites(favorites, max(0, favorite_limit)) - selected.extend(self._other_games_window(others, max(0, other_limit))) - if not selected and other_limit > 0: - # Nothing survived at all: your teams are not playing inside the - # schedule window AND the filters removed every other game. Each - # check fails open on missing data, but a filter working exactly as - # asked can still match nothing on a given day, and with no - # favourite game left there is nothing to carry the mode -- an empty - # list is a blank panel, not a short one. Same whole-list fallback - # `_filtered_or_all` makes for a board with no favourites at all. - # `other_limit` of 0 is an explicit "favourites only", so that one - # is left to go quiet as asked. - selected = self._other_games_window(pools["unfiltered"], max(0, other_limit)) - # Re-sort so the card order still reads as a schedule. Selection decides - # WHICH games; it should not reorder them into favourites-then-others, - # which would show next week's UGA game before tonight's. - selected.sort(key=key, reverse=newest_first) - return selected - def _rotate_other_games_on_display(self) -> bool: """Swap in a freshly cut slice when the rotation interval has passed. @@ -2962,8 +2026,6 @@ def __init__( self.game_display_duration = 15 # Display each upcoming game for 15 seconds - - def _select_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] ) -> List[Dict]: @@ -3491,39 +2553,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsRecent(SportsCore): - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mode_config.get( - "recent_update_interval", 3600 - ) # Check for recent games every hour - self.last_game_switch = 0 - self.game_display_duration = self.mode_config.get("recent_game_duration", 15) - self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 - - def _get_zero_clock_duration(self, game_id: str) -> float: - """Track how long a game has been at 0:00 clock.""" - current_time = time.time() - if game_id not in self._zero_clock_timestamps: - self._zero_clock_timestamps[game_id] = current_time - return 0.0 - return current_time - self._zero_clock_timestamps[game_id] - - def _clear_zero_clock_tracking(self, game_id: str) -> None: - """Clear tracking when game clock moves away from 0:00 or game ends.""" - if game_id in self._zero_clock_timestamps: - del self._zero_clock_timestamps[game_id] +class SportsRecent(SportsRecentSharedMixin, SportsCore): def _select_recent_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] @@ -4107,7 +3137,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -4286,76 +3316,6 @@ def _is_game_really_over(self, game: Dict) -> bool: ) return False - def _detect_stale_games(self, games: List[Dict]) -> None: - """Remove games that appear stale or haven't updated.""" - current_time = time.time() - - for game in games[:]: # Copy list to iterate safely - game_id = game.get("id") - if not game_id: - continue - - # Check if game data is stale - timestamps = self.game_update_timestamps.get(game_id, {}) - last_seen = timestamps.get("last_seen", 0) - - if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: - self.logger.warning( - f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(last seen {int(current_time - last_seen)}s ago)" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - continue - - # Also check if game appears to be over - if self._is_game_really_over(game): - self.logger.debug( - f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - - def _idle_live_interval(self) -> int: - """How long to wait before looking for live games again, when there are none. - - Escalates the longer nothing turns up, and any live game resets it, so - an in-season gap between games costs at most one escalated wait while - an out-of-season league stops polling on a live cadence entirely. - - Capped rather than unbounded: the cost of backing off is how late the - first game after a quiet spell is noticed, and past the cap the saving - stops being worth that. - """ - streak = getattr(self, "_empty_live_streak", 0) - base = self.no_data_interval - ceiling = getattr(self, "live_idle_max_interval", - _DEFAULT_LIVE_IDLE_MAX_SECONDS) - # The ceiling bounds the un-escalated interval too. The two settings are - # independent integers with no cross-validation, so base > ceiling is a - # reachable config -- and returning base unclamped there made the wait - # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), - # the opposite of what the setting named "maximum" promises. - if streak >= _IDLE_LONG_STREAK: - return min(int(base * _IDLE_LONG_FACTOR), ceiling) - if streak >= _IDLE_SHORT_STREAK: - return min(int(base * _IDLE_SHORT_FACTOR), ceiling) - return min(base, ceiling) - - def _note_live_fetch(self, found_live: bool) -> None: - """Record whether a look for live games found any.""" - if found_live: - if getattr(self, "_empty_live_streak", 0): - self.logger.info( - "Live games found after %d empty check(s); back to the " - "live update interval", self._empty_live_streak) - self._empty_live_streak = 0 - else: - self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 - def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: diff --git a/plugins/basketball-scoreboard/test_favorite_live_boost.py b/plugins/basketball-scoreboard/test_favorite_live_boost.py index 5af65ba6..23c7a5bf 100644 --- a/plugins/basketball-scoreboard/test_favorite_live_boost.py +++ b/plugins/basketball-scoreboard/test_favorite_live_boost.py @@ -15,6 +15,7 @@ Run: /bin/python plugins/basketball-scoreboard/test_favorite_live_boost.py """ +import os import sys import types from pathlib import Path @@ -35,6 +36,23 @@ def mod(name, **attrs): mod("src") mod("src.logo_downloader", LogoDownloader=object, download_missing_logo=lambda *a, **k: None) + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + _stub_core_src() diff --git a/plugins/basketball-scoreboard/test_non_favorite_live_duration.py b/plugins/basketball-scoreboard/test_non_favorite_live_duration.py index be10272d..f8d82d6c 100644 --- a/plugins/basketball-scoreboard/test_non_favorite_live_duration.py +++ b/plugins/basketball-scoreboard/test_non_favorite_live_duration.py @@ -9,6 +9,7 @@ Run: /bin/python plugins/basketball-scoreboard/test_non_favorite_live_duration.py """ +import os import sys import types from pathlib import Path @@ -29,6 +30,23 @@ def mod(name, **attrs): mod("src") mod("src.logo_downloader", LogoDownloader=object, download_missing_logo=lambda *a, **k: None) + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + _stub_core_src() diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 744ed909..a2eddfb8 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "3.1.1", + "version": "3.3.0", "update_interval": 60, "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", @@ -25,6 +25,13 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "3.3.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "The sports.py logic every scoreboard shares moves to the core. Forty-five method bodies here were byte-identical to the same forty-five in every other scoreboard -- the selection and rotation engine, the font, colour and date subsystem, and the switch-mode upcoming card -- so they now come from src.common.sports_shared and this plugin inherits them. 1037 lines removed here. A fix to any of that now reaches every scoreboard at once instead of needing eight identical edits. Three deliberately stayed behind: _get_timezone, because it binds a per-plugin timezone module whose contents differ, and the two abstract stubs that define what makes this sport its own. Nothing drawn changes -- the bodies moved rather than being rewritten, and all 176 safety-harness renders across the eight plugins are byte-identical to before. The floor rises to 3.3.0, the release that first ships the shared module.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "3.1.1", "released": "2026-09-02", @@ -389,7 +396,7 @@ "released": "2026-07-10", "version": "2.8.0", "ledmatrix_min_version": "2.0.0", - "notes": "Adaptive layout (beta, opt-in): set layout_mode: \"adaptive\" to scale fonts/logos/regions to any panel size. Default stays \"classic\" \u2014 rendering is unchanged unless you opt in; switch back to classic in config to revert without reinstalling. Adaptive mode also applies customization.layout x/y offsets in scroll mode (classic scroll never did). User-configured fonts win over adaptive sizing." + "notes": "Adaptive layout (beta, opt-in): set layout_mode: \"adaptive\" to scale fonts/logos/regions to any panel size. Default stays \"classic\" — rendering is unchanged unless you opt in; switch back to classic in config to revert without reinstalling. Adaptive mode also applies customization.layout x/y offsets in scroll mode (classic scroll never did). User-configured fonts win over adaptive sizing." }, { "released": "2026-07-08", @@ -644,7 +651,7 @@ "config_schema": "config_schema.json", "entry_point": "manager.py", "compatible_versions": [ - ">=3.2.0" + ">=3.3.0" ], "display": { "design_size": { diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index 9448d4aa..8bbc4702 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -37,6 +37,8 @@ # rules): a deferred bare-name import could bind another plugin's # game_renderer after namespace isolation. from game_renderer import GameRenderer +from src.common.sports_shared import ( + SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin) def _resolve_font_path(path: str) -> str: @@ -234,7 +236,7 @@ def _fetch(cls, key, sport_key, team_id, abbr, logo_path, logo_url, logger): return fetched -class SportsCore(ABC): +class SportsCore(SportsCoreSharedMixin, ABC): def __init__( self, config: Dict[str, Any], @@ -434,24 +436,6 @@ def __init__( "Background service not available - using synchronous fetching" ) - def _get_season_schedule_dates(self) -> tuple[str, str]: - return "", "" - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Placeholder draw method - subclasses should override.""" - # This base method will be simple, subclasses provide specifics - try: - img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - status = game.get("status_text", "N/A") - self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) - self.display_manager.image.paste(img, (0, 0)) - # Don't call update_display here, let subclasses handle it after drawing - except Exception as e: - self.logger.error( - f"Error in base _draw_scorebug_layout: {e}", exc_info=True - ) - def _adaptive_scorebug(self, game: Dict, game_type: str, force_clear: bool = False) -> bool: """Render the scorebug via the adaptive GameRenderer when @@ -542,62 +526,6 @@ def display(self, force_clear: bool = False) -> bool: 'four_by_six': '4x6-font.ttf', } - @classmethod - def _crisp_size(cls, font_file, desired): - """Snap *desired* to the nearest size *font_file* renders crisply at. - - A face with no known grid is returned unchanged, so a user-supplied - font is never second-guessed. - """ - font_file = cls._FONT_NAME_ALIASES.get(font_file, font_file) - grid = cls._FONT_PIXEL_GRID.get(font_file) - if not grid or not desired or desired <= 0: - return desired - return max(grid, int(round(float(desired) / grid)) * grid) - - def _schema_font_size(self, element_key): - """The font_size this plugin's config_schema.json declares, or None.""" - if not element_key: - return None - cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None) - if cache is None: - cache = {} - try: - import json - schema_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'config_schema.json') - with open(schema_path) as fh: - schema = json.load(fh) - props = (schema.get('properties', {}) - .get('customization', {}) - .get('properties', {})) - for key, spec in props.items(): - size = spec.get('properties', {}).get('font_size', {}).get('default') - if size is not None: - cache[key] = int(size) - except Exception: - cache = {} - self.__class__._SCHEMA_FONT_SIZES = cache - return cache.get(element_key) - - def _resolve_font_size(self, element_config, element_key, default_size, font_name): - """Size to render at: the user's choice, or a grid-snapped default. - - A configured size counts as a real choice only when it differs from - the schema default. The web UI writes the whole schema default block - on every save, so "font_size == schema default" carries no intent and - would otherwise pin every install to an anti-aliased size forever. - """ - configured = (element_config or {}).get('font_size') - if configured is not None: - try: - configured = int(configured) - if configured != self._schema_font_size(element_key): - return configured - except (TypeError, ValueError): - pass - return self._crisp_size(font_name, default_size) - def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], default_size: int = 8, element_key=None, default_font: Optional[str] = None) -> ImageFont.FreeTypeFont: """ Load a custom font from an element configuration dictionary. @@ -691,124 +619,6 @@ def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", ) - def _card_option(self, key: str, default: Any = None) -> Any: - """Read one key from the scroll_card config block.""" - block = (self.config or {}).get("scroll_card") - if isinstance(block, dict) and block.get(key) is not None: - return block.get(key) - return default - - def _switch_upcoming_center(self) -> str: - """Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'.""" - mode = str(self._card_option("switch_upcoming_center", "date_time") - or "date_time").lower() - if mode == "inherit": - mode = str(self._card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time", "none") else "date_time" - - def _vs_text(self) -> str: - """Separator drawn between the teams -- "VS", "@", "at", anything.""" - return str(self._card_option("vs_text", "VS")) - - def _switch_date_format(self) -> str: - """Date style for the full-screen scorebug. - - Its own key rather than the shared ``date_format`` because the two - displays disagree about the default: the scroll card renders "Sep 19" - while _extract_game_details_common emits "9/19", the "numeric" style, - and this scorebug has always drawn it. Reading the shared key here - would restyle every existing panel on update -- and "leave it alone - when unset" is not available, because the core merges schema defaults - into the config on every load, so the key is never actually unset. - "inherit" opts into the scroll and Vegas setting. - """ - fmt = str(self._card_option("switch_date_format", "numeric") or "numeric").lower() - if fmt == "inherit": - fmt = str(self._card_option("date_format", "abbrev") or "abbrev").lower() - return fmt - - def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: - """Format an upcoming date per scroll_card.switch_date_format.""" - raw = str(date_text or "").strip() - if not raw: - return raw - fmt = self._switch_date_format() - if fmt == "numeric": - return raw - parts = raw.replace("-", "/").split("/") - if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): - return raw - month, day = int(parts[0]), int(parts[1]) - if not 1 <= month <= 12: - return raw - name = self._MONTH_ABBR[month - 1] - if fmt == "numeric_day_first": - return f"{day}/{month}" - if fmt == "day_first": - return f"{day} {name}" - if fmt == "weekday": - weekday = self._weekday_for(game) - return f"{weekday} {name} {day}" if weekday else f"{name} {day}" - return f"{name} {day}" - - def _weekday_for(self, game: Optional[Dict]) -> str: - """Weekday abbreviation from the game's start time, or ''.""" - if not game: - return "" - raw = game.get("start_time_utc") or game.get("start_time") - if not raw: - return "" - try: - start = raw if isinstance(raw, datetime) else datetime.fromisoformat( - str(raw).replace("Z", "+00:00")) - return self._WEEKDAY_ABBR[start.astimezone(self._get_timezone()).weekday()] - except (ValueError, TypeError, OverflowError): - return "" - - def _format_game_time(self, time_text: str) -> str: - """Return the time as-is (12h) or converted to 24h.""" - raw = str(time_text or "").strip() - if not raw or str(self._card_option("time_format", "12h")) != "24h": - return raw - cleaned = raw.upper().replace(" ", "") - meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" - if not meridiem: - return raw - try: - hh, _, mm = cleaned[:-2].partition(":") - hour, minute = int(hh), int(mm or 0) - except ValueError: - return raw - if not (0 <= hour <= 12 and 0 <= minute <= 59): - return raw - hour = hour % 12 + (12 if meridiem == "PM" else 0) - return f"{hour:02d}:{minute:02d}" - - def _scorebug_font(self, draw, text: str, width: int): - """The face this scorebug draws its date and time in. - - Always the "time" face, which is what this display has used for both - rows for as long as it has existed: changing switch_upcoming_center - moves the two lines around, it is not meant to restyle them, so the - type stays put while the placement changes. - - The single exception is text that cannot fit the panel at all. Only - the "weekday" date can do that -- "Fri Sep 19" measures 80px in an - 8px face, on a board 64px wide -- and the smaller "detail" face is a - better answer there than running off both edges. Every other date and - time this display can produce fits, so in practice the face never - changes; it is a floor, not a style rule. - """ - font = self.fonts["time"] - if not text: - return font - try: - if draw.textlength(text, font=font) + 2 <= width: - return font - except (TypeError, ValueError): - return font - return self.fonts.get("detail") or font - def _upcoming_date_and_time_text(self, game_date: str, game_time: str, game: Optional[Dict] = None) -> Tuple[str, str]: """The formatted (date, time) pair, blanked by switch_show_date/_time. @@ -826,106 +636,6 @@ def _upcoming_date_and_time_text(self, game_date: str, game_time: str, if self._card_option("switch_show_time", True) else "") return date_text, time_text - def _draw_upcoming_center_switch(self, draw, game: Dict, center_y: int, - game_date: str, game_time: str, - display_width: Optional[int] = None, - display_height: Optional[int] = None, - date_element: str = 'date', - time_element: str = 'time', - second_row_y_offset: bool = True) -> bool: - """Draw the middle of the full-screen upcoming scorebug. - - Returns True when the header above it ("Next Game", or the league - name) should still be drawn. In "vs" and "none" the date and time move - out of the middle and into the top and bottom slots, mirroring the - scroll card -- and the top slot is where the header used to be, so the - caller drops it. - - ``date_element``/``time_element``/``second_row_y_offset`` exist only so - the layout-offset keys stay exactly what each plugin's schema - advertises; this sport's defaults are the common case. - """ - width = self.display_width if display_width is None else display_width - height = self.display_height if display_height is None else display_height - mode = self._switch_upcoming_center() - date_text, time_text = self._upcoming_date_and_time_text( - game_date, game_time, game) - swapped = bool(self._card_option("swap_date_time", False)) - - if mode == "date_time": - # Historically the date sat at center_y - 7 with the time 9px - # under it, and the time's row was derived from the date's, so a - # date y_offset moved the pair. Both still hold; the slots only - # trade places when swap_date_time is set, and hiding one line - # leaves the other where it was rather than re-centering the stack. - slots = [(time_element, time_text), (date_element, date_text)] if swapped \ - else [(date_element, date_text), (time_element, time_text)] - row_y = center_y - 7 - for index, (element, text) in enumerate(slots): - if index: - row_y += 9 - if second_row_y_offset: - row_y += self._get_layout_offset(element, 'y_offset') - else: - row_y += self._get_layout_offset(element, 'y_offset') - if not text: - continue - font = self._scorebug_font(draw, text, width) - text_width = draw.textlength(text, font=font) - text_x = ((width - text_width) // 2 - + self._get_layout_offset(element, 'x_offset')) - self._draw_text_with_outline( - draw, text, (text_x, row_y), font - ) - return True - - if mode == "vs": - vs_text = self._vs_text() - if vs_text: - vs_width = draw.textlength(vs_text, font=self.fonts["score"]) - vs_x = ((width - vs_width) // 2 - + self._get_layout_offset('score', 'x_offset')) - vs_y = (center_y - 3 - + self._get_layout_offset('score', 'y_offset')) - self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts["score"] - ) - - # "vs" and "none" both push the date and time out to the edges, time - # on top unless swap_date_time says otherwise -- the same order the - # scroll card uses. - if swapped: - top_element, top_text = date_element, date_text - bottom_element, bottom_text = time_element, time_text - else: - top_element, top_text = time_element, time_text - bottom_element, bottom_text = date_element, date_text - - if top_text: - top_font = self._scorebug_font(draw, top_text, width) - top_width = draw.textlength(top_text, font=top_font) - top_x = ((width - top_width) // 2 - + self._get_layout_offset(top_element, 'x_offset')) - top_y = 1 + self._get_layout_offset(top_element, 'y_offset') - self._draw_text_with_outline( - draw, top_text, (top_x, top_y), top_font - ) - if bottom_text: - bottom_font = self._scorebug_font(draw, bottom_text, width) - bottom_width = draw.textlength(bottom_text, font=bottom_font) - bottom_x = ((width - bottom_width) // 2 - + self._get_layout_offset(bottom_element, 'x_offset')) - # Measured, not a fixed offset: the detail font is 6px in most - # plugins and 10px in soccer and nrl, where a fixed -7 ran the - # date off the panel. - ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] - bottom_y = (max(0, height - ink_bottom - 1) - + self._get_layout_offset(bottom_element, 'y_offset')) - self._draw_text_with_outline( - draw, bottom_text, (bottom_x, bottom_y), bottom_font - ) - return False - def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: """ Get layout offset for a specific element and axis. @@ -990,86 +700,6 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: "tie": (255, 200, 0), } - @staticmethod - def _coerce_rgb(value, fallback): - """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" - # Checked before unpacking: a 3-character string ("123") would otherwise - # iterate into three digits and yield a colour rather than the fallback. - if not isinstance(value, (list, tuple)) or len(value) != 3: - return fallback - try: - r, g, b = (max(0, min(255, int(channel))) for channel in value) - except (TypeError, ValueError): - return fallback - return (r, g, b) - - @staticmethod - def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: - """Is the home/away side of this game a favorite team? - - Both the abbreviation and the ESPN id are checked, because a couple of - leagues (NRL) match favorites by id where abbreviations collide. - """ - for key in (f"{side}_abbr", f"{side}_id"): - value = game.get(key) - if value is not None and str(value).strip().upper() in favorites: - return True - return False - - def _favorite_result(self, game: Dict) -> Optional[str]: - """Say how the favorite team did in a finished game. - - Returns 'win', 'loss' or 'tie', or None when there is no single team - to root for: no favorites configured, neither side is a favorite, or - *both* are -- a favorite-vs-favorite game has no losing side worth - flagging in red. Also None when the scores are not usable numbers. - """ - favorites = getattr(self, "favorite_teams", None) or [] - favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} - if not favorites: - return None - - home_fav = self._side_is_favorite(game, "home", favorites) - away_fav = self._side_is_favorite(game, "away", favorites) - if home_fav == away_fav: - return None - - try: - # int(float(...)) to match GameRenderer._side_score exactly -- the - # two paths must agree on what counts as a usable score. - home_score = int(float(str(game.get("home_score", "")).strip())) - away_score = int(float(str(game.get("away_score", "")).strip())) - except (TypeError, ValueError): - return None - - if home_score == away_score: - return "tie" - favorite_score, other_score = ( - (home_score, away_score) if home_fav else (away_score, home_score) - ) - return "win" if favorite_score > other_score else "loss" - - def _recent_score_color(self, game: Dict, default): - """Fill color for a finished game's score, per favorite_result_colors.""" - try: - settings = (self.config.get("customization") or {}).get( - "favorite_result_colors" - ) or {} - if not settings.get("enabled", False): - return default - result = self._favorite_result(game) - if result is None: - return default - return self._coerce_rgb( - settings.get(f"{result}_color"), - self.FAVORITE_RESULT_COLOR_DEFAULTS[result], - ) - except Exception: - self.logger.debug( - "Could not resolve favorite result color", exc_info=True - ) - return default - #: Score may occupy this share of the panel width before the layout #: reaches for a narrower face. Above it the score crowds out the logos #: and the clock; below it the design face is kept. @@ -1162,137 +792,6 @@ def _fit_score_font(self, fonts: dict) -> dict: #: -- so on a taller panel they grew and the score did not. _FONT_DESIGN_HEIGHT: ClassVar[int] = 32 - def _score_font_size(self) -> int: - """Pixel size the score is currently drawn at.""" - return getattr(self.fonts.get("score"), "size", 8) or 8 - - def _time_font_size(self) -> int: - """Pixel size the clock/date face is currently drawn at.""" - return getattr(self.fonts.get("time"), "size", 8) or 8 - - def _user_chose_size(self, element_key: str) -> bool: - """True when customization..font_size is a real choice. - - The web UI's save flow writes the whole schema default block into - config.json on every save, whether or not the user touched that - section, so a size merely being PRESENT carries no intent. Only one - that differs from the schema default does. - """ - element = (self.config.get('customization', {}) or {}).get(element_key) or {} - configured = element.get('font_size') - if configured is None: - return False - try: - return int(configured) != self._schema_font_size(element_key) - except (TypeError, ValueError): - return False - - def _grid_scaled_size(self, font): - """(path, grid, size) for *font* regrown to this panel's height. - - None when the panel is at or below the design height (nothing to do), - or when the face has no known pixel grid -- a user-supplied font is - never second-guessed, because we do not know what it renders crisply - at. - """ - path = getattr(font, 'path', None) - base = getattr(font, 'size', None) - if not base or not isinstance(path, str): - return None - face = os.path.basename(path) - grid = self._FONT_PIXEL_GRID.get(self._FONT_NAME_ALIASES.get(face, face)) - if not grid: - return None - scale = float(self.display_height) / (self._FONT_DESIGN_HEIGHT or 32) - if scale <= 1.0: - return None - return path, grid, max(int(base), int(self._crisp_size(face, base * scale))) - - def _scale_headline_fonts(self, fonts): - """Grow the score with the panel, and hold the clock/date below it. - - The score is the one number the card exists to show, and it was the - only element not sized from the panel. Worse, it was not even bigger - than its neighbours: PressStart2P renders crisply on an 8px grid, so - the 10px default snapped to 8 -- the same 8 the period/clock above it - and the game date below it are drawn at. Three lines of identical - type, none of them the headline, which is what makes the score read as - lower priority than the time and the date rather than the point of the - card. - - So the score is sized from display_height and snapped to its face's - pixel grid (off the grid FreeType anti-aliases the strokes, and on an - LED matrix a part-lit pixel is a dim lamp rather than a soft edge), - then stepped back down that grid until it fits its share of the width. - The clock/date face is regrown the same way but held at least one grid - step below the score, so the ranking between them is visible rather - than implied. - - A 32-tall panel scales by exactly 1.0 and is left byte-identical; a - size the user set explicitly is never overridden. - """ - self._score_grew = False - if not self._DRAWS_SCORE: - # No score on this screen, so none of the sizing below is for it. - return fonts - try: - scaled = None if self._user_chose_size('score_text') else \ - self._grid_scaled_size(fonts.get('score')) - if scaled is not None: - path, grid, size = scaled - base = getattr(fonts['score'], 'size', size) or size - size = min(size, base * self._SCORE_MAX_GROWTH) - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - # Measured from a fixed five-character score rather than the - # live one, so the card does not resize when a side passes 9. - while size > grid: - if probe.textlength( - self._SCORE_PROBE_TEXT, - font=ImageFont.truetype(path, size)) <= budget: - break - size -= grid - if size != getattr(fonts['score'], 'size', size): - fonts['score'] = ImageFont.truetype(path, size) - self._score_grew = True - - if not self._score_grew and not self._user_chose_size('score_text') \ - and self.display_height > self._FONT_DESIGN_HEIGHT: - # PressStart2P could not grow inside the budget -- its next crisp - # size is simply too wide for this panel. A narrower face still - # can: 4x6-font at 14px is nearly as tall as PressStart2P at 16 - # and about half as wide. This matters beyond the score itself, - # because a card whose score never grows never reserves the - # centre either, so its logos stay at the uncapped 1.5x and are - # drawn straight over the score -- which is what a three-digit - # basketball score does on a 128x64 board. - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - current = getattr(fonts.get('score'), 'size', 0) or 0 - for _name, _size in self._NARROW_SCORE_RUNGS: - if _size <= current: - continue - _path = _resolve_font_path(f"assets/fonts/{_name}") - _candidate = ImageFont.truetype(_path, _size) - if probe.textlength(self._SCORE_PROBE_TEXT, - font=_candidate) <= budget: - fonts['score'] = _candidate - self._score_grew = True - break - - scaled = None if self._user_chose_size('period_text') else \ - self._grid_scaled_size(fonts.get('time')) - if scaled is not None: - path, grid, size = scaled - ceiling = getattr(fonts.get('score'), 'size', 0) or 0 - if ceiling and size >= ceiling: - size = max(grid, ceiling - grid) - if size != getattr(fonts['time'], 'size', size): - fonts['time'] = ImageFont.truetype(path, size) - except Exception: - self.logger.debug("Headline font scaling skipped", exc_info=True) - return fonts - def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1485,105 +984,6 @@ def _draw_dynamic_odds( "rank": "rank_text", } - def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): - """Per-element text colour from customization..text_color.""" - try: - cfg = (self.config or {}).get("customization", {}).get(element, {}) - value = cfg.get("text_color") - if isinstance(value, (list, tuple)) and len(value) == 3: - return tuple(max(0, min(255, int(c))) for c in value) - if isinstance(value, str) and value.startswith("#") and len(value) == 7: - return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) - except (TypeError, ValueError): - pass - return default - - def _unshare_element_fonts(self, fonts): - """Give each colourable element its own face object. - - The colour a draw gets is resolved from the face it was handed, and - several of these loaders legitimately hand one object to more than one - element -- a size resolver that lands two elements on the same face, a - fallback that fills every key from one default, football's narrowing - step that deliberately shrinks the clock along with the score. Sharing - the object makes the element ambiguous and the colour unresolvable. - - Re-instantiating from the same path and size gives a distinct object - with identical metrics, so nothing about the rendering changes; only - the ability to tell two elements apart does. Faces that cannot be - rebuilt (a BDF loaded through freetype.Face, anything without a usable - path) are left shared, and their draws stay white as before. - """ - try: - from PIL import ImageFont as _IF - except ImportError: # pragma: no cover - return fonts - seen = {} - for key in self._ELEMENT_FOR_FONT: - font = fonts.get(key) - if font is None: - continue - if id(font) not in seen: - seen[id(font)] = key - continue - path, size = getattr(font, "path", None), getattr(font, "size", None) - if not path or not size: - continue - try: - fonts[key] = _IF.truetype(path, size) - except (OSError, ValueError, TypeError): - self.logger.debug( - "Could not un-share the %s face; it keeps the default colour", key) - return fonts - - def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): - """Colour for whichever element owns this face. - - Matched on identity, and deliberately gives up when one object is - shared: the last-resort font path can hand the same face to several - keys, and there is no right answer for which element's colour that is. - White is what those draws used before, so ambiguity costs nothing. - """ - try: - fonts = getattr(self, "fonts", None) or {} - matches = [element for key, element in self._ELEMENT_FOR_FONT.items() - if fonts.get(key) is font] - if len(matches) == 1: - return self._element_color(matches[0], default) - except (AttributeError, TypeError): - pass - return default - - def _draw_text_with_outline( - self, draw, text, position, font, fill=None, outline_color=(0, 0, 0) - ): - """Draw text with a black outline for better readability.""" - # Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get - # anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying - # glyphs. 1-bit mode keeps strokes crisp. - # Defaults to the configured colour for whichever element owns - # this face rather than to white, so customization..text_color - # reaches every draw. The schema has offered those pickers all along - # and they only ever changed the font. An explicit fill still wins: - # the odds colours and the favourite-result score tint mean something - # the palette does not. - if fill is None: - fill = self._font_color(font) - draw.fontmode = "1" - x, y = position - for dx, dy in [ - (-1, -1), - (-1, 0), - (-1, 1), - (0, -1), - (0, 1), - (1, -1), - (1, 0), - (1, 1), - ]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - def _fit_text(self, draw, candidates, font, max_width: int) -> str: """First candidate that fits *max_width*, else the last one, else "". @@ -1824,14 +1224,6 @@ def _get_timezone(self): log=self.logger, ) - def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: - """Check if we should log a warning based on cooldown period.""" - current_time = time.time() - if current_time - self._last_warning_time > cooldown: - self._last_warning_time = current_time - return True - return False - def _choose_poll(self, rankings_data: List[Dict]) -> Dict: """The first poll ESPN lists that is not a lower-division one. @@ -1853,34 +1245,6 @@ def _choose_poll(self, rankings_data: List[Dict]) -> Dict: return block return {} - def _normalise_quality(self, raw) -> str: - """other_games_min_quality, as one of the values the code implements. - - An unusable value used to fall through every branch of - _passes_other_filters and silently mean "any" -- a quality bar the - board believes it has and does not. - """ - value = str(raw or "").strip().lower() - if value in self._QUALITY_CHOICES: - return value - if value == "broadcast": - # Retired. Measured against a real Week 1 and Week 2 college slate - # it passed 174 of 175 games: ESPN publishes a broadcaster for - # nearly everything now, ESPN+ included, so the tier read as a - # quality bar and behaved as "any". Boards holding it get the bar - # they thought they were getting. - self.logger.warning( - "%s: other_games_min_quality 'broadcast' has been retired -- " - "it let through nearly every game -- using 'ranked'. Change " - "the setting to clear this.", getattr(self, "sport_key", "?"), - ) - return "ranked" - self.logger.warning( - "%s: ignoring unusable other_games_min_quality=%r, using 'ranked'", - getattr(self, "sport_key", "?"), raw, - ) - return "ranked" - def _fetch_team_rankings(self) -> Dict[str, int]: """Fetch team rankings using the new architecture components.""" current_time = time.time() @@ -2200,58 +1564,6 @@ def _fetch_todays_games(self) -> Optional[Dict]: ) return None - def _get_weeks_data(self) -> Optional[Dict]: - """ - Get partial data for immediate display while background fetch is in progress. - This fetches current/recent games only for quick response. - """ - try: - # Fetch current week and next few days for immediate display - now = datetime.now(pytz.utc) - immediate_events = [] - - start_date = now - timedelta(days=self.schedule_lookback_days) - end_date = now + timedelta(days=self.schedule_lookahead_days) - date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - response = self.session.get( - url, - params={"dates": date_str, "limit": 1000}, - headers=self.headers, - timeout=10, - ) - response.raise_for_status() - data = response.json() - immediate_events = data.get("events", []) - - if immediate_events: - self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") - return {"events": immediate_events} - - except requests.exceptions.RequestException as e: - self.logger.warning( - f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}" - ) - return None - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - pass - - def cleanup(self): - """Clean up resources when plugin is unloaded.""" - # Close HTTP session - if hasattr(self, 'session') and self.session: - try: - self.session.close() - except Exception as e: - self.logger.warning(f"Error closing session: {e}") - - # Clear caches - if hasattr(self, '_logo_cache'): - self._logo_cache.clear() - - self.logger.info(f"{self.__class__.__name__} cleanup completed") - def _is_favorite_game(self, game: Dict) -> bool: """Does either side of this game belong to a favourite team?""" @@ -2396,64 +1708,6 @@ def _load_division_team_ids(self) -> Dict[str, set]: self._division_team_ids[name] = ids return self._division_team_ids - def _game_divisions(self, game: Dict) -> Optional[set]: - """Divisions of BOTH sides, or None when they cannot be told. - - Both sides are collected, but the caller only needs ONE of them to sit - in a checked division. Requiring every participant read as "FBS games - only" and removed a ranked side hosting an FCS school -- which is still - a game involving a team the viewer checked the box for, and on a real - Week 2 slate it silently dropped five of the twenty ranked matchups. - What the checkbox is for is keeping FCS-versus-FCS out of a board - configured for FBS, and that still holds: a game with no checked - division on either side is dropped. - """ - divisions = self._load_division_team_ids() - if not any(divisions.values()): - return None - try: - ids = [int(game.get("home_id")), int(game.get("away_id"))] - except (TypeError, ValueError): - return None - present = set() - for team_id in ids: - for name in ("fbs", "fcs"): - if team_id in divisions.get(name, set()): - present.add(name) - break - else: - present.add("other") - return present - - def _league_has_rankings(self) -> bool: - """Only college leagues publish a poll; everyone else 404s. - - This gate matters more than it looks. _fetch_team_rankings only - short-circuits when the cache is non-empty, so a failed fetch leaves it - empty and the next update tries again -- at a 30s interval that is - ~2,900 pointless requests a day, per league, all of them 404s. - """ - league = (self.league or "").lower() - return "college" in league or "ncaa" in league - - @staticmethod - def _normalise_divisions(raw) -> List[str]: - """Division names from config, in the shape the filter expects. - - A hand-edited config can hold "fbs" where the schema says ["fbs"], and - list("fbs") is ['f', 'b', 's'] -- three names that match no division, so - every non-favourite game is rejected by a setting the user believes says - the opposite. An empty list is left empty: that means "no division - filter" and is a legitimate choice, not a mistake to correct. - """ - if isinstance(raw, str): - raw = [raw] - try: - items = list(raw or []) - except TypeError: - return [] - return [str(d).strip().lower() for d in items if str(d).strip()] - def _setting_int(self, key: str, default: int, low: int, high: int) -> int: """A count from config, clamped to the range its schema declares. @@ -2513,64 +1767,6 @@ def _best_rank(self, game: Dict) -> int: found = self._ranked_positions(game) return min(found) if found else 99 - def _round_robin_favorites(self, games: List[Dict], limit: int) -> List[Dict]: - """Each favourite team's next game before any team's second one. - - Taking the soonest N favourite games spends the slots on whoever plays - most often. Walked across a real season with two favourites and a limit - of 2, nine days of it showed Auburn twice and Georgia not at all -- - Auburn played either side of a Georgia bye, so both slots went to - Auburn. The other-games pool already refuses to do this; favourites - were still doing it. - - Depth is kept where there is room: one favourite with three slots still - gets its next three games, because the round-robin only comes back for - a team's second game once every team has had a first. - - A game between two favourites is picked once and counts for both. - """ - if limit <= 0 or not games: - return [] - wanted = [t for t in (self.favorite_teams or []) if t] - if len(wanted) < 2: - return games[:limit] # nothing to share the slots between - - # Which side of a game belongs to which favourite is a per-lineage - # question: NRL matches on ESPN team IDs because its abbreviations are - # not unique ("NEW" is both Newcastle and New Zealand), while the rest - # match on abbreviation. Ask for the lineage's own matcher rather than - # assuming, or this silently groups nothing and every slot goes empty. - team_in = getattr(self, "_team_in", None) - if callable(team_in): - def belongs(game, team): - return bool(team_in(game.get("home_id"), [team]) - or team_in(game.get("away_id"), [team])) - else: - def belongs(game, team): - return team in (game.get("home_abbr"), game.get("away_abbr")) - - queues = {team: [] for team in wanted} - for game in games: # already in kickoff order - for team in wanted: - if belongs(game, team): - queues[team].append(game) - - picked, taken = [], set() - while len(picked) < limit: - progressed = False - for team in wanted: - queue = queues[team] - while queue and queue[0].get("id") in taken: - queue.pop(0) - if queue and len(picked) < limit: - game = queue.pop(0) - taken.add(game.get("id")) - picked.append(game) - progressed = True - if not progressed: - break # every queue is empty - return picked - def _by_importance(self, games: List[Dict], newest_first: bool = False) -> List[Dict]: """Non-favourite games, best matchup first. @@ -2653,44 +1849,6 @@ def _passes_other_filters(self, game: Dict) -> bool: return False return True - def _check_ranking_coverage(self, games: List[Dict]) -> None: - """Say so when a loaded poll matches nothing on the schedule. - - The table is keyed by the abbreviation the RANKINGS endpoint returns and - matched against the one the SCOREBOARD endpoint returns. Nothing - guarantees the two agree, and if they ever stop agreeing the filter - quietly removes every non-favourite game -- no exception, no log line, - just a shorter board. That is the same shape as the bug where rankings - were never loading at all, which survived until someone went looking. - - Throttled to once an hour: selection runs on every update. - """ - if self.other_games_min_quality != "ranked": - return - rankings = getattr(self, "_team_rankings_cache", None) or {} - if not rankings or not games: - return - if any(self._is_ranked_game(g) for g in games): - return - now = time.monotonic() - # Zero means never logged, not "logged at the epoch". monotonic() counts - # from an arbitrary origin -- on a freshly booted board it is a few - # hundred seconds -- so comparing against 0 swallowed the first warning - # for the first hour of uptime, which is exactly when a misconfigured - # board is being watched. CI caught this; a machine with days of uptime - # cannot. - if (self._ranking_coverage_logged_at - and now - self._ranking_coverage_logged_at < self._RANKING_COVERAGE_SECONDS): - return - self._ranking_coverage_logged_at = now - self.logger.warning( - "%s: %d ranked teams loaded, but none of the %d other games match " - "one -- the quality filter is removing every non-favourite game. " - "Ranked abbreviations look like: %s", - self.league, len(rankings), len(games), - ", ".join(sorted(rankings)[:8]), - ) - def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: """A rotating slice of the non-favourite games. @@ -2737,99 +1895,6 @@ def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: window += others[:limit - len(window)] return window - def _favorites_first( - self, - processed_games: List[Dict], - favorite_limit: int, - other_limit: int, - newest_first: bool = False, - ) -> List[Dict]: - """Favourite games first, then a bounded number of everything else. - - This is the middle setting the plugin was missing. `show_favorite_teams_only` - used to be the whole story: on, and you saw nothing but your teams; off, - and your teams were ignored entirely -- the selection just took the next - N games league-wide, so a UGA fan with 946 upcoming college games in the - window saw UGA about as often as chance allowed. - - Both counts are TOTALS here, not per-team. In favourites-only mode - `upcoming_games_to_show` is a per-team budget, which is reasonable when - the list is your own teams; applied to a dynamic group it is not. With - AP_TOP_10 resolving to a dozen teams, three games each is 28 distinct - cards before a single non-favourite is added. A total keeps the rotation - the length the user asked for. - """ - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key, reverse=True) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key) - - favorites, others, unfiltered = [], [], [] - for game in ordered: - if self._is_favorite_game(game): - favorites.append(game) # never filtered: your team is your team - continue - unfiltered.append(game) - if self._passes_other_filters(game): - others.append(game) - self._check_ranking_coverage(unfiltered) - - self._selection_pools = { - "favorites": favorites, - "others": self._by_importance(others, newest_first), - "unfiltered": self._by_importance(unfiltered, newest_first), - "favorite_limit": favorite_limit, - "other_limit": other_limit, - "newest_first": newest_first, - } - return self._compose_selection() - - def _compose_selection(self) -> List[Dict]: - """Favourites plus the current slice of others, in schedule order. - - Split out of _favorites_first so the slice can be re-cut between - fetches. The pools are settled -- which games exist, and which of them - are worth a slot -- while WHICH of the others is on screen is a display - decision, and gating it on the fetch made the rotation interval a lie: - update() returns early until upcoming_update_interval has passed, so a - four-minute rotation actually stepped fifteen windows once an hour. - Same lesson as _advance_live_game_if_due further down this file. - """ - pools = self._selection_pools - favorites, others = pools["favorites"], pools["others"] - favorite_limit, other_limit = pools["favorite_limit"], pools["other_limit"] - newest_first = pools["newest_first"] - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - - selected = self._round_robin_favorites(favorites, max(0, favorite_limit)) - selected.extend(self._other_games_window(others, max(0, other_limit))) - if not selected and other_limit > 0: - # Nothing survived at all: your teams are not playing inside the - # schedule window AND the filters removed every other game. Each - # check fails open on missing data, but a filter working exactly as - # asked can still match nothing on a given day, and with no - # favourite game left there is nothing to carry the mode -- an empty - # list is a blank panel, not a short one. Same whole-list fallback - # makes for a board with no favourites at all, which now takes - # this same path with a favourite limit of 0. - # `other_limit` of 0 is an explicit "favourites only", so that one - # is left to go quiet as asked. - selected = self._other_games_window(pools["unfiltered"], max(0, other_limit)) - # Re-sort so the card order still reads as a schedule. Selection decides - # WHICH games; it should not reorder them into favourites-then-others, - # which would show next week's UGA game before tonight's. - selected.sort(key=key, reverse=newest_first) - return selected - def _rotate_other_games_on_display(self) -> bool: """Swap in a freshly cut slice when the rotation interval has passed. @@ -3571,39 +2636,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsRecent(SportsCore): - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mode_config.get( - "recent_update_interval", 3600 - ) # Check for recent games every hour - self.last_game_switch = 0 - self.game_display_duration = self.mode_config.get("recent_game_duration", 15) - self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 - - def _get_zero_clock_duration(self, game_id: str) -> float: - """Track how long a game has been at 0:00 clock.""" - current_time = time.time() - if game_id not in self._zero_clock_timestamps: - self._zero_clock_timestamps[game_id] = current_time - return 0.0 - return current_time - self._zero_clock_timestamps[game_id] - - def _clear_zero_clock_tracking(self, game_id: str) -> None: - """Clear tracking when game clock moves away from 0:00 or game ends.""" - if game_id in self._zero_clock_timestamps: - del self._zero_clock_timestamps[game_id] +class SportsRecent(SportsRecentSharedMixin, SportsCore): def _select_recent_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] @@ -4184,7 +3217,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -4725,76 +3758,6 @@ def _is_game_really_over(self, game: Dict) -> bool: ) return False - def _detect_stale_games(self, games: List[Dict]) -> None: - """Remove games that appear stale or haven't updated.""" - current_time = time.time() - - for game in games[:]: # Copy list to iterate safely - game_id = game.get("id") - if not game_id: - continue - - # Check if game data is stale - timestamps = self.game_update_timestamps.get(game_id, {}) - last_seen = timestamps.get("last_seen", 0) - - if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: - self.logger.warning( - f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(last seen {int(current_time - last_seen)}s ago)" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - continue - - # Also check if game appears to be over - if self._is_game_really_over(game): - self.logger.debug( - f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - - def _idle_live_interval(self) -> int: - """How long to wait before looking for live games again, when there are none. - - Escalates the longer nothing turns up, and any live game resets it, so - an in-season gap between games costs at most one escalated wait while - an out-of-season league stops polling on a live cadence entirely. - - Capped rather than unbounded: the cost of backing off is how late the - first game after a quiet spell is noticed, and past the cap the saving - stops being worth that. - """ - streak = getattr(self, "_empty_live_streak", 0) - base = self.no_data_interval - ceiling = getattr(self, "live_idle_max_interval", - _DEFAULT_LIVE_IDLE_MAX_SECONDS) - # The ceiling bounds the un-escalated interval too. The two settings are - # independent integers with no cross-validation, so base > ceiling is a - # reachable config -- and returning base unclamped there made the wait - # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), - # the opposite of what the setting named "maximum" promises. - if streak >= _IDLE_LONG_STREAK: - return min(int(base * _IDLE_LONG_FACTOR), ceiling) - if streak >= _IDLE_SHORT_STREAK: - return min(int(base * _IDLE_SHORT_FACTOR), ceiling) - return min(base, ceiling) - - def _note_live_fetch(self, found_live: bool) -> None: - """Record whether a look for live games found any.""" - if found_live: - if getattr(self, "_empty_live_streak", 0): - self.logger.info( - "Live games found after %d empty check(s); back to the " - "live update interval", self._empty_live_streak) - self._empty_live_streak = 0 - else: - self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 - def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: diff --git a/plugins/football-scoreboard/test_adaptive_layout_mode.py b/plugins/football-scoreboard/test_adaptive_layout_mode.py index 36a85062..fab444ac 100644 --- a/plugins/football-scoreboard/test_adaptive_layout_mode.py +++ b/plugins/football-scoreboard/test_adaptive_layout_mode.py @@ -32,6 +32,23 @@ LOGOS = os.path.join(PLUGIN_DIR, "assets", "sports", "nfl_logos") # test_score_celebration.py's standalone-run shim replaces sys.modules["src"] + +# The stubs above are plain ModuleTypes, so `from src.common.X import Y` +# fails with "'src.common' is not a package" even when a real core is on +# the path. Giving them a __path__ lets genuine submodules -- sports_shared, +# sports_card -- resolve from the core while the stubbed ones stay stubbed. +# Stubbing those too would make this test pass against dummies instead of +# the code under test. +_core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) +if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] # with a fake package, which clobbers src.adaptive_layout when both files # share a pytest session. Evict the stub (the real core package is importable # here — this file runs from the core tree), then import the real module so diff --git a/plugins/football-scoreboard/test_score_celebration.py b/plugins/football-scoreboard/test_score_celebration.py index 19ad607b..cacb7517 100644 --- a/plugins/football-scoreboard/test_score_celebration.py +++ b/plugins/football-scoreboard/test_score_celebration.py @@ -60,6 +60,23 @@ def _stub_download_missing_logo(*a, **k): sys.modules["src"] = src_pkg sys.modules["src.logo_downloader"] = logo_mod + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + logging.basicConfig(level=logging.ERROR) diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 1b2c61bb..c53c617d 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.22.1", + "version": "1.24.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -18,7 +18,7 @@ ], "icon": "fas fa-hockey-puck", "compatible_versions": [ - ">=3.2.0" + ">=3.3.0" ], "requires": { "python": ">=3.9", @@ -54,6 +54,13 @@ } ], "versions": [ + { + "version": "1.24.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "The sports.py logic every scoreboard shares moves to the core. Forty-five method bodies here were byte-identical to the same forty-five in every other scoreboard -- the selection and rotation engine, the font, colour and date subsystem, and the switch-mode upcoming card -- so they now come from src.common.sports_shared and this plugin inherits them. 1040 lines removed here. A fix to any of that now reaches every scoreboard at once instead of needing eight identical edits. Three deliberately stayed behind: _get_timezone, because it binds a per-plugin timezone module whose contents differ, and the two abstract stubs that define what makes this sport its own. Nothing drawn changes -- the bodies moved rather than being rewritten, and all 176 safety-harness renders across the eight plugins are byte-identical to before. The floor rises to 3.3.0, the release that first ships the shared module.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.22.1", "released": "2026-09-02", diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 80aea088..94cdfee8 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -38,6 +38,8 @@ from base_odds_manager import BaseOddsManager from data_sources import ESPNDataSource from hockey_timezone import resolve_timezone +from src.common.sports_shared import ( + SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin) def _resolve_font_path(path: str) -> str: @@ -83,7 +85,6 @@ def _resolve_font_path(path: str) -> str: return path - _DEFAULT_LOOKBACK_DAYS = 14 _DEFAULT_LOOKAHEAD_DAYS = 7 _MIN_WINDOW_DAYS = 1 @@ -152,7 +153,7 @@ def _logo_needs_refresh(logo_file) -> bool: return False -class SportsCore(ABC): +class SportsCore(SportsCoreSharedMixin, ABC): def __init__( self, config: Dict[str, Any], @@ -349,24 +350,6 @@ def __init__( "Background service not available - using synchronous fetching" ) - def _get_season_schedule_dates(self) -> tuple[str, str]: - return "", "" - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Placeholder draw method - subclasses should override.""" - # This base method will be simple, subclasses provide specifics - try: - img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - status = game.get("status_text", "N/A") - self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) - self.display_manager.image.paste(img, (0, 0)) - # Don't call update_display here, let subclasses handle it after drawing - except Exception as e: - self.logger.error( - f"Error in base _draw_scorebug_layout: {e}", exc_info=True - ) - def display(self, force_clear: bool = False) -> bool: """ Common display method for all managers. @@ -424,62 +407,6 @@ def display(self, force_clear: bool = False) -> bool: 'four_by_six': '4x6-font.ttf', } - @classmethod - def _crisp_size(cls, font_file, desired): - """Snap *desired* to the nearest size *font_file* renders crisply at. - - A face with no known grid is returned unchanged, so a user-supplied - font is never second-guessed. - """ - font_file = cls._FONT_NAME_ALIASES.get(font_file, font_file) - grid = cls._FONT_PIXEL_GRID.get(font_file) - if not grid or not desired or desired <= 0: - return desired - return max(grid, int(round(float(desired) / grid)) * grid) - - def _schema_font_size(self, element_key): - """The font_size this plugin's config_schema.json declares, or None.""" - if not element_key: - return None - cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None) - if cache is None: - cache = {} - try: - import json - schema_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'config_schema.json') - with open(schema_path) as fh: - schema = json.load(fh) - props = (schema.get('properties', {}) - .get('customization', {}) - .get('properties', {})) - for key, spec in props.items(): - size = spec.get('properties', {}).get('font_size', {}).get('default') - if size is not None: - cache[key] = int(size) - except Exception: - cache = {} - self.__class__._SCHEMA_FONT_SIZES = cache - return cache.get(element_key) - - def _resolve_font_size(self, element_config, element_key, default_size, font_name): - """Size to render at: the user's choice, or a grid-snapped default. - - A configured size counts as a real choice only when it differs from - the schema default. The web UI writes the whole schema default block - on every save, so "font_size == schema default" carries no intent and - would otherwise pin every install to an anti-aliased size forever. - """ - configured = (element_config or {}).get('font_size') - if configured is not None: - try: - configured = int(configured) - if configured != self._schema_font_size(element_key): - return configured - except (TypeError, ValueError): - pass - return self._crisp_size(font_name, default_size) - def _load_custom_font_from_element_config( self, element_config: Dict[str, Any], @@ -577,124 +504,6 @@ def _load_custom_font_from_element_config( "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", ) - def _card_option(self, key: str, default: Any = None) -> Any: - """Read one key from the scroll_card config block.""" - block = (self.config or {}).get("scroll_card") - if isinstance(block, dict) and block.get(key) is not None: - return block.get(key) - return default - - def _switch_upcoming_center(self) -> str: - """Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'.""" - mode = str(self._card_option("switch_upcoming_center", "date_time") - or "date_time").lower() - if mode == "inherit": - mode = str(self._card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time", "none") else "date_time" - - def _vs_text(self) -> str: - """Separator drawn between the teams -- "VS", "@", "at", anything.""" - return str(self._card_option("vs_text", "VS")) - - def _switch_date_format(self) -> str: - """Date style for the full-screen scorebug. - - Its own key rather than the shared ``date_format`` because the two - displays disagree about the default: the scroll card renders "Sep 19" - while _extract_game_details_common emits "9/19", the "numeric" style, - and this scorebug has always drawn it. Reading the shared key here - would restyle every existing panel on update -- and "leave it alone - when unset" is not available, because the core merges schema defaults - into the config on every load, so the key is never actually unset. - "inherit" opts into the scroll and Vegas setting. - """ - fmt = str(self._card_option("switch_date_format", "numeric") or "numeric").lower() - if fmt == "inherit": - fmt = str(self._card_option("date_format", "abbrev") or "abbrev").lower() - return fmt - - def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: - """Format an upcoming date per scroll_card.switch_date_format.""" - raw = str(date_text or "").strip() - if not raw: - return raw - fmt = self._switch_date_format() - if fmt == "numeric": - return raw - parts = raw.replace("-", "/").split("/") - if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): - return raw - month, day = int(parts[0]), int(parts[1]) - if not 1 <= month <= 12: - return raw - name = self._MONTH_ABBR[month - 1] - if fmt == "numeric_day_first": - return f"{day}/{month}" - if fmt == "day_first": - return f"{day} {name}" - if fmt == "weekday": - weekday = self._weekday_for(game) - return f"{weekday} {name} {day}" if weekday else f"{name} {day}" - return f"{name} {day}" - - def _weekday_for(self, game: Optional[Dict]) -> str: - """Weekday abbreviation from the game's start time, or ''.""" - if not game: - return "" - raw = game.get("start_time_utc") or game.get("start_time") - if not raw: - return "" - try: - start = raw if isinstance(raw, datetime) else datetime.fromisoformat( - str(raw).replace("Z", "+00:00")) - return self._WEEKDAY_ABBR[start.astimezone(self._get_timezone()).weekday()] - except (ValueError, TypeError, OverflowError): - return "" - - def _format_game_time(self, time_text: str) -> str: - """Return the time as-is (12h) or converted to 24h.""" - raw = str(time_text or "").strip() - if not raw or str(self._card_option("time_format", "12h")) != "24h": - return raw - cleaned = raw.upper().replace(" ", "") - meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" - if not meridiem: - return raw - try: - hh, _, mm = cleaned[:-2].partition(":") - hour, minute = int(hh), int(mm or 0) - except ValueError: - return raw - if not (0 <= hour <= 12 and 0 <= minute <= 59): - return raw - hour = hour % 12 + (12 if meridiem == "PM" else 0) - return f"{hour:02d}:{minute:02d}" - - def _scorebug_font(self, draw, text: str, width: int): - """The face this scorebug draws its date and time in. - - Always the "time" face, which is what this display has used for both - rows for as long as it has existed: changing switch_upcoming_center - moves the two lines around, it is not meant to restyle them, so the - type stays put while the placement changes. - - The single exception is text that cannot fit the panel at all. Only - the "weekday" date can do that -- "Fri Sep 19" measures 80px in an - 8px face, on a board 64px wide -- and the smaller "detail" face is a - better answer there than running off both edges. Every other date and - time this display can produce fits, so in practice the face never - changes; it is a floor, not a style rule. - """ - font = self.fonts["time"] - if not text: - return font - try: - if draw.textlength(text, font=font) + 2 <= width: - return font - except (TypeError, ValueError): - return font - return self.fonts.get("detail") or font - def _upcoming_date_and_time_text(self, game_date: str, game_time: str, game: Optional[Dict] = None) -> Tuple[str, str]: """The formatted (date, time) pair, blanked by show_date/show_time.""" @@ -704,106 +513,6 @@ def _upcoming_date_and_time_text(self, game_date: str, game_time: str, if self._card_option("show_time", True) else "") return date_text, time_text - def _draw_upcoming_center_switch(self, draw, game: Dict, center_y: int, - game_date: str, game_time: str, - display_width: Optional[int] = None, - display_height: Optional[int] = None, - date_element: str = 'date', - time_element: str = 'time', - second_row_y_offset: bool = True) -> bool: - """Draw the middle of the full-screen upcoming scorebug. - - Returns True when the header above it ("Next Game", or the league - name) should still be drawn. In "vs" and "none" the date and time move - out of the middle and into the top and bottom slots, mirroring the - scroll card -- and the top slot is where the header used to be, so the - caller drops it. - - ``date_element``/``time_element``/``second_row_y_offset`` exist only so - the layout-offset keys stay exactly what each plugin's schema - advertises; this sport's defaults are the common case. - """ - width = self.display_width if display_width is None else display_width - height = self.display_height if display_height is None else display_height - mode = self._switch_upcoming_center() - date_text, time_text = self._upcoming_date_and_time_text( - game_date, game_time, game) - swapped = bool(self._card_option("swap_date_time", False)) - - if mode == "date_time": - # Historically the date sat at center_y - 7 with the time 9px - # under it, and the time's row was derived from the date's, so a - # date y_offset moved the pair. Both still hold; the slots only - # trade places when swap_date_time is set, and hiding one line - # leaves the other where it was rather than re-centering the stack. - slots = [(time_element, time_text), (date_element, date_text)] if swapped \ - else [(date_element, date_text), (time_element, time_text)] - row_y = center_y - 7 - for index, (element, text) in enumerate(slots): - if index: - row_y += 9 - if second_row_y_offset: - row_y += self._get_layout_offset(element, 'y_offset') - else: - row_y += self._get_layout_offset(element, 'y_offset') - if not text: - continue - font = self._scorebug_font(draw, text, width) - text_width = draw.textlength(text, font=font) - text_x = ((width - text_width) // 2 - + self._get_layout_offset(element, 'x_offset')) - self._draw_text_with_outline( - draw, text, (text_x, row_y), font - ) - return True - - if mode == "vs": - vs_text = self._vs_text() - if vs_text: - vs_width = draw.textlength(vs_text, font=self.fonts["score"]) - vs_x = ((width - vs_width) // 2 - + self._get_layout_offset('score', 'x_offset')) - vs_y = (center_y - 3 - + self._get_layout_offset('score', 'y_offset')) - self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts["score"] - ) - - # "vs" and "none" both push the date and time out to the edges, time - # on top unless swap_date_time says otherwise -- the same order the - # scroll card uses. - if swapped: - top_element, top_text = date_element, date_text - bottom_element, bottom_text = time_element, time_text - else: - top_element, top_text = time_element, time_text - bottom_element, bottom_text = date_element, date_text - - if top_text: - top_font = self._scorebug_font(draw, top_text, width) - top_width = draw.textlength(top_text, font=top_font) - top_x = ((width - top_width) // 2 - + self._get_layout_offset(top_element, 'x_offset')) - top_y = 1 + self._get_layout_offset(top_element, 'y_offset') - self._draw_text_with_outline( - draw, top_text, (top_x, top_y), top_font - ) - if bottom_text: - bottom_font = self._scorebug_font(draw, bottom_text, width) - bottom_width = draw.textlength(bottom_text, font=bottom_font) - bottom_x = ((width - bottom_width) // 2 - + self._get_layout_offset(bottom_element, 'x_offset')) - # Measured, not a fixed offset: the detail font is 6px in most - # plugins and 10px in soccer and nrl, where a fixed -7 ran the - # date off the panel. - ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] - bottom_y = (max(0, height - ink_bottom - 1) - + self._get_layout_offset(bottom_element, 'y_offset')) - self._draw_text_with_outline( - draw, bottom_text, (bottom_x, bottom_y), bottom_font - ) - return False - def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: """ Get layout offset for a specific element and axis. @@ -857,86 +566,6 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: "tie": (255, 200, 0), } - @staticmethod - def _coerce_rgb(value, fallback): - """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" - # Checked before unpacking: a 3-character string ("123") would otherwise - # iterate into three digits and yield a colour rather than the fallback. - if not isinstance(value, (list, tuple)) or len(value) != 3: - return fallback - try: - r, g, b = (max(0, min(255, int(channel))) for channel in value) - except (TypeError, ValueError): - return fallback - return (r, g, b) - - @staticmethod - def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: - """Is the home/away side of this game a favorite team? - - Both the abbreviation and the ESPN id are checked, because a couple of - leagues (NRL) match favorites by id where abbreviations collide. - """ - for key in (f"{side}_abbr", f"{side}_id"): - value = game.get(key) - if value is not None and str(value).strip().upper() in favorites: - return True - return False - - def _favorite_result(self, game: Dict) -> Optional[str]: - """Say how the favorite team did in a finished game. - - Returns 'win', 'loss' or 'tie', or None when there is no single team - to root for: no favorites configured, neither side is a favorite, or - *both* are -- a favorite-vs-favorite game has no losing side worth - flagging in red. Also None when the scores are not usable numbers. - """ - favorites = getattr(self, "favorite_teams", None) or [] - favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} - if not favorites: - return None - - home_fav = self._side_is_favorite(game, "home", favorites) - away_fav = self._side_is_favorite(game, "away", favorites) - if home_fav == away_fav: - return None - - try: - # int(float(...)) to match GameRenderer._side_score exactly -- the - # two paths must agree on what counts as a usable score. - home_score = int(float(str(game.get("home_score", "")).strip())) - away_score = int(float(str(game.get("away_score", "")).strip())) - except (TypeError, ValueError): - return None - - if home_score == away_score: - return "tie" - favorite_score, other_score = ( - (home_score, away_score) if home_fav else (away_score, home_score) - ) - return "win" if favorite_score > other_score else "loss" - - def _recent_score_color(self, game: Dict, default): - """Fill color for a finished game's score, per favorite_result_colors.""" - try: - settings = (self.config.get("customization") or {}).get( - "favorite_result_colors" - ) or {} - if not settings.get("enabled", False): - return default - result = self._favorite_result(game) - if result is None: - return default - return self._coerce_rgb( - settings.get(f"{result}_color"), - self.FAVORITE_RESULT_COLOR_DEFAULTS[result], - ) - except Exception: - self.logger.debug( - "Could not resolve favorite result color", exc_info=True - ) - return default - #: How far each logo is shifted outward, off the panel edge, by the #: scorebug layouts (they paste at -2 and width - logo_width + 2). Kept #: here because the logo sizing has to know it. @@ -1084,137 +713,6 @@ def _fit_score_font(self, fonts): #: -- so on a taller panel they grew and the score did not. _FONT_DESIGN_HEIGHT: ClassVar[int] = 32 - def _score_font_size(self) -> int: - """Pixel size the score is currently drawn at.""" - return getattr(self.fonts.get("score"), "size", 8) or 8 - - def _time_font_size(self) -> int: - """Pixel size the clock/date face is currently drawn at.""" - return getattr(self.fonts.get("time"), "size", 8) or 8 - - def _user_chose_size(self, element_key: str) -> bool: - """True when customization..font_size is a real choice. - - The web UI's save flow writes the whole schema default block into - config.json on every save, whether or not the user touched that - section, so a size merely being PRESENT carries no intent. Only one - that differs from the schema default does. - """ - element = (self.config.get('customization', {}) or {}).get(element_key) or {} - configured = element.get('font_size') - if configured is None: - return False - try: - return int(configured) != self._schema_font_size(element_key) - except (TypeError, ValueError): - return False - - def _grid_scaled_size(self, font): - """(path, grid, size) for *font* regrown to this panel's height. - - None when the panel is at or below the design height (nothing to do), - or when the face has no known pixel grid -- a user-supplied font is - never second-guessed, because we do not know what it renders crisply - at. - """ - path = getattr(font, 'path', None) - base = getattr(font, 'size', None) - if not base or not isinstance(path, str): - return None - face = os.path.basename(path) - grid = self._FONT_PIXEL_GRID.get(self._FONT_NAME_ALIASES.get(face, face)) - if not grid: - return None - scale = float(self.display_height) / (self._FONT_DESIGN_HEIGHT or 32) - if scale <= 1.0: - return None - return path, grid, max(int(base), int(self._crisp_size(face, base * scale))) - - def _scale_headline_fonts(self, fonts): - """Grow the score with the panel, and hold the clock/date below it. - - The score is the one number the card exists to show, and it was the - only element not sized from the panel. Worse, it was not even bigger - than its neighbours: PressStart2P renders crisply on an 8px grid, so - the 10px default snapped to 8 -- the same 8 the period/clock above it - and the game date below it are drawn at. Three lines of identical - type, none of them the headline, which is what makes the score read as - lower priority than the time and the date rather than the point of the - card. - - So the score is sized from display_height and snapped to its face's - pixel grid (off the grid FreeType anti-aliases the strokes, and on an - LED matrix a part-lit pixel is a dim lamp rather than a soft edge), - then stepped back down that grid until it fits its share of the width. - The clock/date face is regrown the same way but held at least one grid - step below the score, so the ranking between them is visible rather - than implied. - - A 32-tall panel scales by exactly 1.0 and is left byte-identical; a - size the user set explicitly is never overridden. - """ - self._score_grew = False - if not self._DRAWS_SCORE: - # No score on this screen, so none of the sizing below is for it. - return fonts - try: - scaled = None if self._user_chose_size('score_text') else \ - self._grid_scaled_size(fonts.get('score')) - if scaled is not None: - path, grid, size = scaled - base = getattr(fonts['score'], 'size', size) or size - size = min(size, base * self._SCORE_MAX_GROWTH) - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - # Measured from a fixed five-character score rather than the - # live one, so the card does not resize when a side passes 9. - while size > grid: - if probe.textlength( - self._SCORE_PROBE_TEXT, - font=ImageFont.truetype(path, size)) <= budget: - break - size -= grid - if size != getattr(fonts['score'], 'size', size): - fonts['score'] = ImageFont.truetype(path, size) - self._score_grew = True - - if not self._score_grew and not self._user_chose_size('score_text') \ - and self.display_height > self._FONT_DESIGN_HEIGHT: - # PressStart2P could not grow inside the budget -- its next crisp - # size is simply too wide for this panel. A narrower face still - # can: 4x6-font at 14px is nearly as tall as PressStart2P at 16 - # and about half as wide. This matters beyond the score itself, - # because a card whose score never grows never reserves the - # centre either, so its logos stay at the uncapped 1.5x and are - # drawn straight over the score -- which is what a three-digit - # basketball score does on a 128x64 board. - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - current = getattr(fonts.get('score'), 'size', 0) or 0 - for _name, _size in self._NARROW_SCORE_RUNGS: - if _size <= current: - continue - _path = _resolve_font_path(f"assets/fonts/{_name}") - _candidate = ImageFont.truetype(_path, _size) - if probe.textlength(self._SCORE_PROBE_TEXT, - font=_candidate) <= budget: - fonts['score'] = _candidate - self._score_grew = True - break - - scaled = None if self._user_chose_size('period_text') else \ - self._grid_scaled_size(fonts.get('time')) - if scaled is not None: - path, grid, size = scaled - ceiling = getattr(fonts.get('score'), 'size', 0) or 0 - if ceiling and size >= ceiling: - size = max(grid, ceiling - grid) - if size != getattr(fonts['time'], 'size', size): - fonts['time'] = ImageFont.truetype(path, size) - except Exception: - self.logger.debug("Headline font scaling skipped", exc_info=True) - return fonts - def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1412,105 +910,6 @@ def _draw_dynamic_odds( "rank": "rank_text", } - def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): - """Per-element text colour from customization..text_color.""" - try: - cfg = (self.config or {}).get("customization", {}).get(element, {}) - value = cfg.get("text_color") - if isinstance(value, (list, tuple)) and len(value) == 3: - return tuple(max(0, min(255, int(c))) for c in value) - if isinstance(value, str) and value.startswith("#") and len(value) == 7: - return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) - except (TypeError, ValueError): - pass - return default - - def _unshare_element_fonts(self, fonts): - """Give each colourable element its own face object. - - The colour a draw gets is resolved from the face it was handed, and - several of these loaders legitimately hand one object to more than one - element -- a size resolver that lands two elements on the same face, a - fallback that fills every key from one default, football's narrowing - step that deliberately shrinks the clock along with the score. Sharing - the object makes the element ambiguous and the colour unresolvable. - - Re-instantiating from the same path and size gives a distinct object - with identical metrics, so nothing about the rendering changes; only - the ability to tell two elements apart does. Faces that cannot be - rebuilt (a BDF loaded through freetype.Face, anything without a usable - path) are left shared, and their draws stay white as before. - """ - try: - from PIL import ImageFont as _IF - except ImportError: # pragma: no cover - return fonts - seen = {} - for key in self._ELEMENT_FOR_FONT: - font = fonts.get(key) - if font is None: - continue - if id(font) not in seen: - seen[id(font)] = key - continue - path, size = getattr(font, "path", None), getattr(font, "size", None) - if not path or not size: - continue - try: - fonts[key] = _IF.truetype(path, size) - except (OSError, ValueError, TypeError): - self.logger.debug( - "Could not un-share the %s face; it keeps the default colour", key) - return fonts - - def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): - """Colour for whichever element owns this face. - - Matched on identity, and deliberately gives up when one object is - shared: the last-resort font path can hand the same face to several - keys, and there is no right answer for which element's colour that is. - White is what those draws used before, so ambiguity costs nothing. - """ - try: - fonts = getattr(self, "fonts", None) or {} - matches = [element for key, element in self._ELEMENT_FOR_FONT.items() - if fonts.get(key) is font] - if len(matches) == 1: - return self._element_color(matches[0], default) - except (AttributeError, TypeError): - pass - return default - - def _draw_text_with_outline( - self, draw, text, position, font, fill=None, outline_color=(0, 0, 0) - ): - """Draw text with a black outline for better readability.""" - # Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get - # anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying - # glyphs. 1-bit mode keeps strokes crisp. - # Defaults to the configured colour for whichever element owns - # this face rather than to white, so customization..text_color - # reaches every draw. The schema has offered those pickers all along - # and they only ever changed the font. An explicit fill still wins: - # the odds colours and the favourite-result score tint mean something - # the palette does not. - if fill is None: - fill = self._font_color(font) - draw.fontmode = "1" - x, y = position - for dx, dy in [ - (-1, -1), - (-1, 0), - (-1, 1), - (0, -1), - (0, 1), - (1, -1), - (1, 0), - (1, 1), - ]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - def _load_and_resize_logo( self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]: @@ -1641,14 +1040,6 @@ def _get_timezone(self): log=self.logger, ) - def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: - """Check if we should log a warning based on cooldown period.""" - current_time = time.time() - if current_time - self._last_warning_time > cooldown: - self._last_warning_time = current_time - return True - return False - # Which ranking block the badge reads. ESPN answers /rankings with more # than one block for several leagues, and the FIRST is not always a poll: # men's and women's college hockey front "NCAA Men's/Women's Hockey @@ -1921,58 +1312,6 @@ def _fetch_todays_games(self) -> Optional[Dict]: ) return None - def _get_weeks_data(self) -> Optional[Dict]: - """ - Get partial data for immediate display while background fetch is in progress. - This fetches current/recent games only for quick response. - """ - try: - # Fetch current week and next few days for immediate display - now = datetime.now(pytz.utc) - immediate_events = [] - - start_date = now - timedelta(days=self.schedule_lookback_days) - end_date = now + timedelta(days=self.schedule_lookahead_days) - date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - response = self.session.get( - url, - params={"dates": date_str, "limit": 1000}, - headers=self.headers, - timeout=10, - ) - response.raise_for_status() - data = response.json() - immediate_events = data.get("events", []) - - if immediate_events: - self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") - return {"events": immediate_events} - - except requests.exceptions.RequestException as e: - self.logger.warning( - f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}" - ) - return None - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - pass - - def cleanup(self): - """Clean up resources when plugin is unloaded.""" - # Close HTTP session - if hasattr(self, 'session') and self.session: - try: - self.session.close() - except Exception as e: - self.logger.warning(f"Error closing session: {e}") - - # Clear caches - if hasattr(self, '_logo_cache'): - self._logo_cache.clear() - - self.logger.info(f"{self.__class__.__name__} cleanup completed") - def _is_favorite_game(self, game: Dict) -> bool: """Does either side of this game belong to a favourite team?""" @@ -2083,64 +1422,6 @@ def _load_division_team_ids(self) -> Dict[str, set]: self._division_team_ids[name] = ids return self._division_team_ids - def _game_divisions(self, game: Dict) -> Optional[set]: - """Divisions of BOTH sides, or None when they cannot be told. - - Both sides are collected, but the caller only needs ONE of them to sit - in a checked division. Requiring every participant read as "FBS games - only" and removed a ranked side hosting an FCS school -- which is still - a game involving a team the viewer checked the box for, and on a real - Week 2 slate it silently dropped five of the twenty ranked matchups. - What the checkbox is for is keeping FCS-versus-FCS out of a board - configured for FBS, and that still holds: a game with no checked - division on either side is dropped. - """ - divisions = self._load_division_team_ids() - if not any(divisions.values()): - return None - try: - ids = [int(game.get("home_id")), int(game.get("away_id"))] - except (TypeError, ValueError): - return None - present = set() - for team_id in ids: - for name in ("fbs", "fcs"): - if team_id in divisions.get(name, set()): - present.add(name) - break - else: - present.add("other") - return present - - def _league_has_rankings(self) -> bool: - """Only college leagues publish a poll; everyone else 404s. - - This gate matters more than it looks. _fetch_team_rankings only - short-circuits when the cache is non-empty, so a failed fetch leaves it - empty and the next update tries again -- at a 30s interval that is - ~2,900 pointless requests a day, per league, all of them 404s. - """ - league = (self.league or "").lower() - return "college" in league or "ncaa" in league - - @staticmethod - def _normalise_divisions(raw) -> List[str]: - """Division names from config, in the shape the filter expects. - - A hand-edited config can hold "fbs" where the schema says ["fbs"], and - list("fbs") is ['f', 'b', 's'] -- three names that match no division, so - every non-favourite game is rejected by a setting the user believes says - the opposite. An empty list is left empty: that means "no division - filter" and is a legitimate choice, not a mistake to correct. - """ - if isinstance(raw, str): - raw = [raw] - try: - items = list(raw or []) - except TypeError: - return [] - return [str(d).strip().lower() for d in items if str(d).strip()] - def _setting_int(self, key: str, default: int, low: int, high: int) -> int: """A count from config, clamped to the range its schema declares. @@ -2178,64 +1459,6 @@ def _best_rank(self, game: Dict) -> int: rankings.get(game.get("away_abbr"), 0)) if r] return min(ranked) if ranked else 99 - def _round_robin_favorites(self, games: List[Dict], limit: int) -> List[Dict]: - """Each favourite team's next game before any team's second one. - - Taking the soonest N favourite games spends the slots on whoever plays - most often. Walked across a real season with two favourites and a limit - of 2, nine days of it showed Auburn twice and Georgia not at all -- - Auburn played either side of a Georgia bye, so both slots went to - Auburn. The other-games pool already refuses to do this; favourites - were still doing it. - - Depth is kept where there is room: one favourite with three slots still - gets its next three games, because the round-robin only comes back for - a team's second game once every team has had a first. - - A game between two favourites is picked once and counts for both. - """ - if limit <= 0 or not games: - return [] - wanted = [t for t in (self.favorite_teams or []) if t] - if len(wanted) < 2: - return games[:limit] # nothing to share the slots between - - # Which side of a game belongs to which favourite is a per-lineage - # question: NRL matches on ESPN team IDs because its abbreviations are - # not unique ("NEW" is both Newcastle and New Zealand), while the rest - # match on abbreviation. Ask for the lineage's own matcher rather than - # assuming, or this silently groups nothing and every slot goes empty. - team_in = getattr(self, "_team_in", None) - if callable(team_in): - def belongs(game, team): - return bool(team_in(game.get("home_id"), [team]) - or team_in(game.get("away_id"), [team])) - else: - def belongs(game, team): - return team in (game.get("home_abbr"), game.get("away_abbr")) - - queues = {team: [] for team in wanted} - for game in games: # already in kickoff order - for team in wanted: - if belongs(game, team): - queues[team].append(game) - - picked, taken = [], set() - while len(picked) < limit: - progressed = False - for team in wanted: - queue = queues[team] - while queue and queue[0].get("id") in taken: - queue.pop(0) - if queue and len(picked) < limit: - game = queue.pop(0) - taken.add(game.get("id")) - picked.append(game) - progressed = True - if not progressed: - break # every queue is empty - return picked - def _by_importance(self, games: List[Dict], newest_first: bool = False) -> List[Dict]: """Non-favourite games, best matchup first. @@ -2300,35 +1523,6 @@ def key(game): #: migrates to "ranked" -- see _normalise_quality. _QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"}) - def _normalise_quality(self, raw) -> str: - """other_games_min_quality, as one of the values the code implements. - - An unusable value used to fall through every branch of - _passes_other_filters and silently mean "any" -- a quality bar the - board believes it has and does not. - """ - value = str(raw or "").strip().lower() - if value in self._QUALITY_CHOICES: - return value - if value == "broadcast": - # Retired in football-scoreboard 3.0.0 and now here. Measured - # against a real Week 1 and Week 2 college slate it passed 174 of - # 175 games: ESPN publishes a broadcaster for nearly everything - # now, ESPN+ included, so the tier read as a quality bar and - # behaved as "any". Boards holding it get the bar they thought - # they were getting. - self.logger.warning( - "%s: other_games_min_quality 'broadcast' has been retired -- " - "it let through nearly every game -- using 'ranked'. Change " - "the setting to clear this.", getattr(self, "sport_key", "?"), - ) - return "ranked" - self.logger.warning( - "%s: ignoring unusable other_games_min_quality=%r, using 'ranked'", - getattr(self, "sport_key", "?"), raw, - ) - return "ranked" - def _passes_other_filters(self, game: Dict) -> bool: """Is this non-favourite game worth one of the remaining slots? @@ -2368,44 +1562,6 @@ def _filtered_or_all(self, games: List[Dict]) -> List[Dict]: return kept or games - def _check_ranking_coverage(self, games: List[Dict]) -> None: - """Say so when a loaded poll matches nothing on the schedule. - - The table is keyed by the abbreviation the RANKINGS endpoint returns and - matched against the one the SCOREBOARD endpoint returns. Nothing - guarantees the two agree, and if they ever stop agreeing the filter - quietly removes every non-favourite game -- no exception, no log line, - just a shorter board. That is the same shape as the bug where rankings - were never loading at all, which survived until someone went looking. - - Throttled to once an hour: selection runs on every update. - """ - if self.other_games_min_quality != "ranked": - return - rankings = getattr(self, "_team_rankings_cache", None) or {} - if not rankings or not games: - return - if any(self._is_ranked_game(g) for g in games): - return - now = time.monotonic() - # Zero means never logged, not "logged at the epoch". monotonic() counts - # from an arbitrary origin -- on a freshly booted board it is a few - # hundred seconds -- so comparing against 0 swallowed the first warning - # for the first hour of uptime, which is exactly when a misconfigured - # board is being watched. CI caught this; a machine with days of uptime - # cannot. - if (self._ranking_coverage_logged_at - and now - self._ranking_coverage_logged_at < self._RANKING_COVERAGE_SECONDS): - return - self._ranking_coverage_logged_at = now - self.logger.warning( - "%s: %d ranked teams loaded, but none of the %d other games match " - "one -- the quality filter is removing every non-favourite game. " - "Ranked abbreviations look like: %s", - self.league, len(rankings), len(games), - ", ".join(sorted(rankings)[:8]), - ) - def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: """A rotating slice of the non-favourite games. @@ -2444,98 +1600,6 @@ def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: window += others[:limit - len(window)] return window - def _favorites_first( - self, - processed_games: List[Dict], - favorite_limit: int, - other_limit: int, - newest_first: bool = False, - ) -> List[Dict]: - """Favourite games first, then a bounded number of everything else. - - This is the middle setting the plugin was missing. `show_favorite_teams_only` - used to be the whole story: on, and you saw nothing but your teams; off, - and your teams were ignored entirely -- the selection just took the next - N games league-wide, so a UGA fan with 946 upcoming college games in the - window saw UGA about as often as chance allowed. - - Both counts are TOTALS here, not per-team. In favourites-only mode - `upcoming_games_to_show` is a per-team budget, which is reasonable when - the list is your own teams; applied to a dynamic group it is not. With - AP_TOP_10 resolving to a dozen teams, three games each is 28 distinct - cards before a single non-favourite is added. A total keeps the rotation - the length the user asked for. - """ - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key, reverse=True) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key) - - favorites, others, unfiltered = [], [], [] - for game in ordered: - if self._is_favorite_game(game): - favorites.append(game) # never filtered: your team is your team - continue - unfiltered.append(game) - if self._passes_other_filters(game): - others.append(game) - self._check_ranking_coverage(unfiltered) - - self._selection_pools = { - "favorites": favorites, - "others": self._by_importance(others, newest_first), - "unfiltered": self._by_importance(unfiltered, newest_first), - "favorite_limit": favorite_limit, - "other_limit": other_limit, - "newest_first": newest_first, - } - return self._compose_selection() - - def _compose_selection(self) -> List[Dict]: - """Favourites plus the current slice of others, in schedule order. - - Split out of _favorites_first so the slice can be re-cut between - fetches. The pools are settled -- which games exist, and which of them - are worth a slot -- while WHICH of the others is on screen is a display - decision, and gating it on the fetch made the rotation interval a lie: - update() returns early until upcoming_update_interval has passed, so a - four-minute rotation actually stepped fifteen windows once an hour. - Same lesson as _advance_live_game_if_due further down this file. - """ - pools = self._selection_pools - favorites, others = pools["favorites"], pools["others"] - favorite_limit, other_limit = pools["favorite_limit"], pools["other_limit"] - newest_first = pools["newest_first"] - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - - selected = self._round_robin_favorites(favorites, max(0, favorite_limit)) - selected.extend(self._other_games_window(others, max(0, other_limit))) - if not selected and other_limit > 0: - # Nothing survived at all: your teams are not playing inside the - # schedule window AND the filters removed every other game. Each - # check fails open on missing data, but a filter working exactly as - # asked can still match nothing on a given day, and with no - # favourite game left there is nothing to carry the mode -- an empty - # list is a blank panel, not a short one. Same whole-list fallback - # `_filtered_or_all` makes for a board with no favourites at all. - # `other_limit` of 0 is an explicit "favourites only", so that one - # is left to go quiet as asked. - selected = self._other_games_window(pools["unfiltered"], max(0, other_limit)) - # Re-sort so the card order still reads as a schedule. Selection decides - # WHICH games; it should not reorder them into favourites-then-others, - # which would show next week's UGA game before tonight's. - selected.sort(key=key, reverse=newest_first) - return selected - def _rotate_other_games_on_display(self) -> bool: """Swap in a freshly cut slice when the rotation interval has passed. @@ -2620,8 +1684,6 @@ def __init__( self.game_display_duration = 15 # Display each upcoming game for 15 seconds - - def _select_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] ) -> List[Dict]: @@ -3136,39 +2198,7 @@ def display(self, force_clear=False) -> bool: return False -class SportsRecent(SportsCore): - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mode_config.get( - "recent_update_interval", 3600 - ) # Check for recent games every hour - self.last_game_switch = 0 - self.game_display_duration = self.mode_config.get("recent_game_duration", 15) - self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 - - def _get_zero_clock_duration(self, game_id: str) -> float: - """Track how long a game has been at 0:00 clock.""" - current_time = time.time() - if game_id not in self._zero_clock_timestamps: - self._zero_clock_timestamps[game_id] = current_time - return 0.0 - return current_time - self._zero_clock_timestamps[game_id] - - def _clear_zero_clock_tracking(self, game_id: str) -> None: - """Clear tracking when game clock moves away from 0:00 or game ends.""" - if game_id in self._zero_clock_timestamps: - del self._zero_clock_timestamps[game_id] +class SportsRecent(SportsRecentSharedMixin, SportsCore): def _select_recent_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] @@ -3682,7 +2712,7 @@ def display(self, force_clear=False) -> bool: return False -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -3860,76 +2890,6 @@ def _is_game_really_over(self, game: Dict) -> bool: ) return False - def _detect_stale_games(self, games: List[Dict]) -> None: - """Remove games that appear stale or haven't updated.""" - current_time = time.time() - - for game in games[:]: # Copy list to iterate safely - game_id = game.get("id") - if not game_id: - continue - - # Check if game data is stale - timestamps = self.game_update_timestamps.get(game_id, {}) - last_seen = timestamps.get("last_seen", 0) - - if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: - self.logger.warning( - f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(last seen {int(current_time - last_seen)}s ago)" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - continue - - # Also check if game appears to be over - if self._is_game_really_over(game): - self.logger.debug( - f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - - def _idle_live_interval(self) -> int: - """How long to wait before looking for live games again, when there are none. - - Escalates the longer nothing turns up, and any live game resets it, so - an in-season gap between games costs at most one escalated wait while - an out-of-season league stops polling on a live cadence entirely. - - Capped rather than unbounded: the cost of backing off is how late the - first game after a quiet spell is noticed, and past the cap the saving - stops being worth that. - """ - streak = getattr(self, "_empty_live_streak", 0) - base = self.no_data_interval - ceiling = getattr(self, "live_idle_max_interval", - _DEFAULT_LIVE_IDLE_MAX_SECONDS) - # The ceiling bounds the un-escalated interval too. The two settings are - # independent integers with no cross-validation, so base > ceiling is a - # reachable config -- and returning base unclamped there made the wait - # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), - # the opposite of what the setting named "maximum" promises. - if streak >= _IDLE_LONG_STREAK: - return min(int(base * _IDLE_LONG_FACTOR), ceiling) - if streak >= _IDLE_SHORT_STREAK: - return min(int(base * _IDLE_SHORT_FACTOR), ceiling) - return min(base, ceiling) - - def _note_live_fetch(self, found_live: bool) -> None: - """Record whether a look for live games found any.""" - if found_live: - if getattr(self, "_empty_live_streak", 0): - self.logger.info( - "Live games found after %d empty check(s); back to the " - "live update interval", self._empty_live_streak) - self._empty_live_streak = 0 - else: - self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 - def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: diff --git a/plugins/hockey-scoreboard/test_favorite_live_boost.py b/plugins/hockey-scoreboard/test_favorite_live_boost.py index 324979fd..26f3c10c 100644 --- a/plugins/hockey-scoreboard/test_favorite_live_boost.py +++ b/plugins/hockey-scoreboard/test_favorite_live_boost.py @@ -13,6 +13,7 @@ Run: /bin/python plugins/hockey-scoreboard/test_favorite_live_boost.py """ +import os import sys import types from pathlib import Path @@ -42,6 +43,23 @@ def mod(name, **attrs): mod("src.plugin_system.base_plugin", BasePlugin=object, VegasDisplayMode=object) mod("src.background_data_service", get_background_service=lambda *a, **k: None) + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + _stub_core_src() diff --git a/plugins/hockey-scoreboard/test_non_favorite_live_duration.py b/plugins/hockey-scoreboard/test_non_favorite_live_duration.py index 9e0af1bb..5a654988 100644 --- a/plugins/hockey-scoreboard/test_non_favorite_live_duration.py +++ b/plugins/hockey-scoreboard/test_non_favorite_live_duration.py @@ -9,6 +9,7 @@ Run: /bin/python plugins/hockey-scoreboard/test_non_favorite_live_duration.py """ +import os import sys import types from pathlib import Path @@ -33,6 +34,23 @@ def mod(name, **attrs): mod("src.plugin_system.base_plugin", BasePlugin=object, VegasDisplayMode=object) mod("src.background_data_service", get_background_service=lambda *a, **k: None) + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + _stub_core_src() diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index 11a9aefe..a3ecbbf5 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.21.1", + "version": "1.23.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -17,7 +17,7 @@ ], "icon": "fas fa-baseball-ball", "compatible_versions": [ - ">=3.2.0" + ">=3.3.0" ], "requires": { "python": ">=3.9", @@ -50,6 +50,13 @@ } ], "versions": [ + { + "version": "1.23.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "The sports.py logic every scoreboard shares moves to the core. Forty-five method bodies here were byte-identical to the same forty-five in every other scoreboard -- the selection and rotation engine, the font, colour and date subsystem, and the switch-mode upcoming card -- so they now come from src.common.sports_shared and this plugin inherits them. 1040 lines removed here. A fix to any of that now reaches every scoreboard at once instead of needing eight identical edits. Three deliberately stayed behind: _get_timezone, because it binds a per-plugin timezone module whose contents differ, and the two abstract stubs that define what makes this sport its own. Nothing drawn changes -- the bodies moved rather than being rewritten, and all 176 safety-harness renders across the eight plugins are byte-identical to before. The floor rises to 3.3.0, the release that first ships the shared module.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.21.1", "released": "2026-09-02", @@ -302,7 +309,7 @@ { "released": "2026-07-02", "version": "1.3.0", - "notes": "Add exclude_teams (hide specific teams from the live rotation and recent/final scores \u2014 spoiler protection, takes precedence over favorite_teams/show_all_live) and favorite_live_boost (tune how many more turns your favorite's live game gets in the rotation vs other live games, 1 = even rotation, default 2).", + "notes": "Add exclude_teams (hide specific teams from the live rotation and recent/final scores — spoiler protection, takes precedence over favorite_teams/show_all_live) and favorite_live_boost (tune how many more turns your favorite's live game gets in the rotation vs other live games, 1 = even rotation, default 2).", "ledmatrix_min": "2.0.0" }, { diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index 3b99dd32..c1599e2f 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -38,6 +38,8 @@ from base_odds_manager import BaseOddsManager from data_sources import ESPNDataSource from lacrosse_timezone import resolve_timezone +from src.common.sports_shared import ( + SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin) def _resolve_font_path(path: str) -> str: @@ -83,7 +85,6 @@ def _resolve_font_path(path: str) -> str: return path - _DEFAULT_LOOKBACK_DAYS = 14 _DEFAULT_LOOKAHEAD_DAYS = 7 _MIN_WINDOW_DAYS = 1 @@ -152,7 +153,7 @@ def _logo_needs_refresh(logo_file) -> bool: return False -class SportsCore(ABC): +class SportsCore(SportsCoreSharedMixin, ABC): def __init__( self, config: Dict[str, Any], @@ -350,24 +351,6 @@ def __init__( "Background service not available - using synchronous fetching" ) - def _get_season_schedule_dates(self) -> tuple[str, str]: - return "", "" - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Placeholder draw method - subclasses should override.""" - # This base method will be simple, subclasses provide specifics - try: - img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - status = game.get("status_text", "N/A") - self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) - self.display_manager.image.paste(img, (0, 0)) - # Don't call update_display here, let subclasses handle it after drawing - except Exception as e: - self.logger.error( - f"Error in base _draw_scorebug_layout: {e}", exc_info=True - ) - def display(self, force_clear: bool = False) -> bool: """ Common display method for all managers. @@ -425,62 +408,6 @@ def display(self, force_clear: bool = False) -> bool: 'four_by_six': '4x6-font.ttf', } - @classmethod - def _crisp_size(cls, font_file, desired): - """Snap *desired* to the nearest size *font_file* renders crisply at. - - A face with no known grid is returned unchanged, so a user-supplied - font is never second-guessed. - """ - font_file = cls._FONT_NAME_ALIASES.get(font_file, font_file) - grid = cls._FONT_PIXEL_GRID.get(font_file) - if not grid or not desired or desired <= 0: - return desired - return max(grid, int(round(float(desired) / grid)) * grid) - - def _schema_font_size(self, element_key): - """The font_size this plugin's config_schema.json declares, or None.""" - if not element_key: - return None - cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None) - if cache is None: - cache = {} - try: - import json - schema_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'config_schema.json') - with open(schema_path) as fh: - schema = json.load(fh) - props = (schema.get('properties', {}) - .get('customization', {}) - .get('properties', {})) - for key, spec in props.items(): - size = spec.get('properties', {}).get('font_size', {}).get('default') - if size is not None: - cache[key] = int(size) - except Exception: - cache = {} - self.__class__._SCHEMA_FONT_SIZES = cache - return cache.get(element_key) - - def _resolve_font_size(self, element_config, element_key, default_size, font_name): - """Size to render at: the user's choice, or a grid-snapped default. - - A configured size counts as a real choice only when it differs from - the schema default. The web UI writes the whole schema default block - on every save, so "font_size == schema default" carries no intent and - would otherwise pin every install to an anti-aliased size forever. - """ - configured = (element_config or {}).get('font_size') - if configured is not None: - try: - configured = int(configured) - if configured != self._schema_font_size(element_key): - return configured - except (TypeError, ValueError): - pass - return self._crisp_size(font_name, default_size) - def _load_custom_font_from_element_config( self, element_config: Dict[str, Any], @@ -578,124 +505,6 @@ def _load_custom_font_from_element_config( "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", ) - def _card_option(self, key: str, default: Any = None) -> Any: - """Read one key from the scroll_card config block.""" - block = (self.config or {}).get("scroll_card") - if isinstance(block, dict) and block.get(key) is not None: - return block.get(key) - return default - - def _switch_upcoming_center(self) -> str: - """Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'.""" - mode = str(self._card_option("switch_upcoming_center", "date_time") - or "date_time").lower() - if mode == "inherit": - mode = str(self._card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time", "none") else "date_time" - - def _vs_text(self) -> str: - """Separator drawn between the teams -- "VS", "@", "at", anything.""" - return str(self._card_option("vs_text", "VS")) - - def _switch_date_format(self) -> str: - """Date style for the full-screen scorebug. - - Its own key rather than the shared ``date_format`` because the two - displays disagree about the default: the scroll card renders "Sep 19" - while _extract_game_details_common emits "9/19", the "numeric" style, - and this scorebug has always drawn it. Reading the shared key here - would restyle every existing panel on update -- and "leave it alone - when unset" is not available, because the core merges schema defaults - into the config on every load, so the key is never actually unset. - "inherit" opts into the scroll and Vegas setting. - """ - fmt = str(self._card_option("switch_date_format", "numeric") or "numeric").lower() - if fmt == "inherit": - fmt = str(self._card_option("date_format", "abbrev") or "abbrev").lower() - return fmt - - def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: - """Format an upcoming date per scroll_card.switch_date_format.""" - raw = str(date_text or "").strip() - if not raw: - return raw - fmt = self._switch_date_format() - if fmt == "numeric": - return raw - parts = raw.replace("-", "/").split("/") - if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): - return raw - month, day = int(parts[0]), int(parts[1]) - if not 1 <= month <= 12: - return raw - name = self._MONTH_ABBR[month - 1] - if fmt == "numeric_day_first": - return f"{day}/{month}" - if fmt == "day_first": - return f"{day} {name}" - if fmt == "weekday": - weekday = self._weekday_for(game) - return f"{weekday} {name} {day}" if weekday else f"{name} {day}" - return f"{name} {day}" - - def _weekday_for(self, game: Optional[Dict]) -> str: - """Weekday abbreviation from the game's start time, or ''.""" - if not game: - return "" - raw = game.get("start_time_utc") or game.get("start_time") - if not raw: - return "" - try: - start = raw if isinstance(raw, datetime) else datetime.fromisoformat( - str(raw).replace("Z", "+00:00")) - return self._WEEKDAY_ABBR[start.astimezone(self._get_timezone()).weekday()] - except (ValueError, TypeError, OverflowError): - return "" - - def _format_game_time(self, time_text: str) -> str: - """Return the time as-is (12h) or converted to 24h.""" - raw = str(time_text or "").strip() - if not raw or str(self._card_option("time_format", "12h")) != "24h": - return raw - cleaned = raw.upper().replace(" ", "") - meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" - if not meridiem: - return raw - try: - hh, _, mm = cleaned[:-2].partition(":") - hour, minute = int(hh), int(mm or 0) - except ValueError: - return raw - if not (0 <= hour <= 12 and 0 <= minute <= 59): - return raw - hour = hour % 12 + (12 if meridiem == "PM" else 0) - return f"{hour:02d}:{minute:02d}" - - def _scorebug_font(self, draw, text: str, width: int): - """The face this scorebug draws its date and time in. - - Always the "time" face, which is what this display has used for both - rows for as long as it has existed: changing switch_upcoming_center - moves the two lines around, it is not meant to restyle them, so the - type stays put while the placement changes. - - The single exception is text that cannot fit the panel at all. Only - the "weekday" date can do that -- "Fri Sep 19" measures 80px in an - 8px face, on a board 64px wide -- and the smaller "detail" face is a - better answer there than running off both edges. Every other date and - time this display can produce fits, so in practice the face never - changes; it is a floor, not a style rule. - """ - font = self.fonts["time"] - if not text: - return font - try: - if draw.textlength(text, font=font) + 2 <= width: - return font - except (TypeError, ValueError): - return font - return self.fonts.get("detail") or font - def _upcoming_date_and_time_text(self, game_date: str, game_time: str, game: Optional[Dict] = None) -> Tuple[str, str]: """The formatted (date, time) pair, blanked by show_date/show_time.""" @@ -705,106 +514,6 @@ def _upcoming_date_and_time_text(self, game_date: str, game_time: str, if self._card_option("show_time", True) else "") return date_text, time_text - def _draw_upcoming_center_switch(self, draw, game: Dict, center_y: int, - game_date: str, game_time: str, - display_width: Optional[int] = None, - display_height: Optional[int] = None, - date_element: str = 'date', - time_element: str = 'time', - second_row_y_offset: bool = True) -> bool: - """Draw the middle of the full-screen upcoming scorebug. - - Returns True when the header above it ("Next Game", or the league - name) should still be drawn. In "vs" and "none" the date and time move - out of the middle and into the top and bottom slots, mirroring the - scroll card -- and the top slot is where the header used to be, so the - caller drops it. - - ``date_element``/``time_element``/``second_row_y_offset`` exist only so - the layout-offset keys stay exactly what each plugin's schema - advertises; this sport's defaults are the common case. - """ - width = self.display_width if display_width is None else display_width - height = self.display_height if display_height is None else display_height - mode = self._switch_upcoming_center() - date_text, time_text = self._upcoming_date_and_time_text( - game_date, game_time, game) - swapped = bool(self._card_option("swap_date_time", False)) - - if mode == "date_time": - # Historically the date sat at center_y - 7 with the time 9px - # under it, and the time's row was derived from the date's, so a - # date y_offset moved the pair. Both still hold; the slots only - # trade places when swap_date_time is set, and hiding one line - # leaves the other where it was rather than re-centering the stack. - slots = [(time_element, time_text), (date_element, date_text)] if swapped \ - else [(date_element, date_text), (time_element, time_text)] - row_y = center_y - 7 - for index, (element, text) in enumerate(slots): - if index: - row_y += 9 - if second_row_y_offset: - row_y += self._get_layout_offset(element, 'y_offset') - else: - row_y += self._get_layout_offset(element, 'y_offset') - if not text: - continue - font = self._scorebug_font(draw, text, width) - text_width = draw.textlength(text, font=font) - text_x = ((width - text_width) // 2 - + self._get_layout_offset(element, 'x_offset')) - self._draw_text_with_outline( - draw, text, (text_x, row_y), font - ) - return True - - if mode == "vs": - vs_text = self._vs_text() - if vs_text: - vs_width = draw.textlength(vs_text, font=self.fonts["score"]) - vs_x = ((width - vs_width) // 2 - + self._get_layout_offset('score', 'x_offset')) - vs_y = (center_y - 3 - + self._get_layout_offset('score', 'y_offset')) - self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts["score"] - ) - - # "vs" and "none" both push the date and time out to the edges, time - # on top unless swap_date_time says otherwise -- the same order the - # scroll card uses. - if swapped: - top_element, top_text = date_element, date_text - bottom_element, bottom_text = time_element, time_text - else: - top_element, top_text = time_element, time_text - bottom_element, bottom_text = date_element, date_text - - if top_text: - top_font = self._scorebug_font(draw, top_text, width) - top_width = draw.textlength(top_text, font=top_font) - top_x = ((width - top_width) // 2 - + self._get_layout_offset(top_element, 'x_offset')) - top_y = 1 + self._get_layout_offset(top_element, 'y_offset') - self._draw_text_with_outline( - draw, top_text, (top_x, top_y), top_font - ) - if bottom_text: - bottom_font = self._scorebug_font(draw, bottom_text, width) - bottom_width = draw.textlength(bottom_text, font=bottom_font) - bottom_x = ((width - bottom_width) // 2 - + self._get_layout_offset(bottom_element, 'x_offset')) - # Measured, not a fixed offset: the detail font is 6px in most - # plugins and 10px in soccer and nrl, where a fixed -7 ran the - # date off the panel. - ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] - bottom_y = (max(0, height - ink_bottom - 1) - + self._get_layout_offset(bottom_element, 'y_offset')) - self._draw_text_with_outline( - draw, bottom_text, (bottom_x, bottom_y), bottom_font - ) - return False - def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: """ Get layout offset for a specific element and axis. @@ -858,86 +567,6 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: "tie": (255, 200, 0), } - @staticmethod - def _coerce_rgb(value, fallback): - """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" - # Checked before unpacking: a 3-character string ("123") would otherwise - # iterate into three digits and yield a colour rather than the fallback. - if not isinstance(value, (list, tuple)) or len(value) != 3: - return fallback - try: - r, g, b = (max(0, min(255, int(channel))) for channel in value) - except (TypeError, ValueError): - return fallback - return (r, g, b) - - @staticmethod - def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: - """Is the home/away side of this game a favorite team? - - Both the abbreviation and the ESPN id are checked, because a couple of - leagues (NRL) match favorites by id where abbreviations collide. - """ - for key in (f"{side}_abbr", f"{side}_id"): - value = game.get(key) - if value is not None and str(value).strip().upper() in favorites: - return True - return False - - def _favorite_result(self, game: Dict) -> Optional[str]: - """Say how the favorite team did in a finished game. - - Returns 'win', 'loss' or 'tie', or None when there is no single team - to root for: no favorites configured, neither side is a favorite, or - *both* are -- a favorite-vs-favorite game has no losing side worth - flagging in red. Also None when the scores are not usable numbers. - """ - favorites = getattr(self, "favorite_teams", None) or [] - favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} - if not favorites: - return None - - home_fav = self._side_is_favorite(game, "home", favorites) - away_fav = self._side_is_favorite(game, "away", favorites) - if home_fav == away_fav: - return None - - try: - # int(float(...)) to match GameRenderer._side_score exactly -- the - # two paths must agree on what counts as a usable score. - home_score = int(float(str(game.get("home_score", "")).strip())) - away_score = int(float(str(game.get("away_score", "")).strip())) - except (TypeError, ValueError): - return None - - if home_score == away_score: - return "tie" - favorite_score, other_score = ( - (home_score, away_score) if home_fav else (away_score, home_score) - ) - return "win" if favorite_score > other_score else "loss" - - def _recent_score_color(self, game: Dict, default): - """Fill color for a finished game's score, per favorite_result_colors.""" - try: - settings = (self.config.get("customization") or {}).get( - "favorite_result_colors" - ) or {} - if not settings.get("enabled", False): - return default - result = self._favorite_result(game) - if result is None: - return default - return self._coerce_rgb( - settings.get(f"{result}_color"), - self.FAVORITE_RESULT_COLOR_DEFAULTS[result], - ) - except Exception: - self.logger.debug( - "Could not resolve favorite result color", exc_info=True - ) - return default - #: How far each logo is shifted outward, off the panel edge, by the #: scorebug layouts (they paste at -2 and width - logo_width + 2). Kept #: here because the logo sizing has to know it. @@ -1085,137 +714,6 @@ def _fit_score_font(self, fonts): #: -- so on a taller panel they grew and the score did not. _FONT_DESIGN_HEIGHT: ClassVar[int] = 32 - def _score_font_size(self) -> int: - """Pixel size the score is currently drawn at.""" - return getattr(self.fonts.get("score"), "size", 8) or 8 - - def _time_font_size(self) -> int: - """Pixel size the clock/date face is currently drawn at.""" - return getattr(self.fonts.get("time"), "size", 8) or 8 - - def _user_chose_size(self, element_key: str) -> bool: - """True when customization..font_size is a real choice. - - The web UI's save flow writes the whole schema default block into - config.json on every save, whether or not the user touched that - section, so a size merely being PRESENT carries no intent. Only one - that differs from the schema default does. - """ - element = (self.config.get('customization', {}) or {}).get(element_key) or {} - configured = element.get('font_size') - if configured is None: - return False - try: - return int(configured) != self._schema_font_size(element_key) - except (TypeError, ValueError): - return False - - def _grid_scaled_size(self, font): - """(path, grid, size) for *font* regrown to this panel's height. - - None when the panel is at or below the design height (nothing to do), - or when the face has no known pixel grid -- a user-supplied font is - never second-guessed, because we do not know what it renders crisply - at. - """ - path = getattr(font, 'path', None) - base = getattr(font, 'size', None) - if not base or not isinstance(path, str): - return None - face = os.path.basename(path) - grid = self._FONT_PIXEL_GRID.get(self._FONT_NAME_ALIASES.get(face, face)) - if not grid: - return None - scale = float(self.display_height) / (self._FONT_DESIGN_HEIGHT or 32) - if scale <= 1.0: - return None - return path, grid, max(int(base), int(self._crisp_size(face, base * scale))) - - def _scale_headline_fonts(self, fonts): - """Grow the score with the panel, and hold the clock/date below it. - - The score is the one number the card exists to show, and it was the - only element not sized from the panel. Worse, it was not even bigger - than its neighbours: PressStart2P renders crisply on an 8px grid, so - the 10px default snapped to 8 -- the same 8 the period/clock above it - and the game date below it are drawn at. Three lines of identical - type, none of them the headline, which is what makes the score read as - lower priority than the time and the date rather than the point of the - card. - - So the score is sized from display_height and snapped to its face's - pixel grid (off the grid FreeType anti-aliases the strokes, and on an - LED matrix a part-lit pixel is a dim lamp rather than a soft edge), - then stepped back down that grid until it fits its share of the width. - The clock/date face is regrown the same way but held at least one grid - step below the score, so the ranking between them is visible rather - than implied. - - A 32-tall panel scales by exactly 1.0 and is left byte-identical; a - size the user set explicitly is never overridden. - """ - self._score_grew = False - if not self._DRAWS_SCORE: - # No score on this screen, so none of the sizing below is for it. - return fonts - try: - scaled = None if self._user_chose_size('score_text') else \ - self._grid_scaled_size(fonts.get('score')) - if scaled is not None: - path, grid, size = scaled - base = getattr(fonts['score'], 'size', size) or size - size = min(size, base * self._SCORE_MAX_GROWTH) - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - # Measured from a fixed five-character score rather than the - # live one, so the card does not resize when a side passes 9. - while size > grid: - if probe.textlength( - self._SCORE_PROBE_TEXT, - font=ImageFont.truetype(path, size)) <= budget: - break - size -= grid - if size != getattr(fonts['score'], 'size', size): - fonts['score'] = ImageFont.truetype(path, size) - self._score_grew = True - - if not self._score_grew and not self._user_chose_size('score_text') \ - and self.display_height > self._FONT_DESIGN_HEIGHT: - # PressStart2P could not grow inside the budget -- its next crisp - # size is simply too wide for this panel. A narrower face still - # can: 4x6-font at 14px is nearly as tall as PressStart2P at 16 - # and about half as wide. This matters beyond the score itself, - # because a card whose score never grows never reserves the - # centre either, so its logos stay at the uncapped 1.5x and are - # drawn straight over the score -- which is what a three-digit - # basketball score does on a 128x64 board. - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - current = getattr(fonts.get('score'), 'size', 0) or 0 - for _name, _size in self._NARROW_SCORE_RUNGS: - if _size <= current: - continue - _path = _resolve_font_path(f"assets/fonts/{_name}") - _candidate = ImageFont.truetype(_path, _size) - if probe.textlength(self._SCORE_PROBE_TEXT, - font=_candidate) <= budget: - fonts['score'] = _candidate - self._score_grew = True - break - - scaled = None if self._user_chose_size('period_text') else \ - self._grid_scaled_size(fonts.get('time')) - if scaled is not None: - path, grid, size = scaled - ceiling = getattr(fonts.get('score'), 'size', 0) or 0 - if ceiling and size >= ceiling: - size = max(grid, ceiling - grid) - if size != getattr(fonts['time'], 'size', size): - fonts['time'] = ImageFont.truetype(path, size) - except Exception: - self.logger.debug("Headline font scaling skipped", exc_info=True) - return fonts - def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1413,105 +911,6 @@ def _draw_dynamic_odds( "rank": "rank_text", } - def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): - """Per-element text colour from customization..text_color.""" - try: - cfg = (self.config or {}).get("customization", {}).get(element, {}) - value = cfg.get("text_color") - if isinstance(value, (list, tuple)) and len(value) == 3: - return tuple(max(0, min(255, int(c))) for c in value) - if isinstance(value, str) and value.startswith("#") and len(value) == 7: - return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) - except (TypeError, ValueError): - pass - return default - - def _unshare_element_fonts(self, fonts): - """Give each colourable element its own face object. - - The colour a draw gets is resolved from the face it was handed, and - several of these loaders legitimately hand one object to more than one - element -- a size resolver that lands two elements on the same face, a - fallback that fills every key from one default, football's narrowing - step that deliberately shrinks the clock along with the score. Sharing - the object makes the element ambiguous and the colour unresolvable. - - Re-instantiating from the same path and size gives a distinct object - with identical metrics, so nothing about the rendering changes; only - the ability to tell two elements apart does. Faces that cannot be - rebuilt (a BDF loaded through freetype.Face, anything without a usable - path) are left shared, and their draws stay white as before. - """ - try: - from PIL import ImageFont as _IF - except ImportError: # pragma: no cover - return fonts - seen = {} - for key in self._ELEMENT_FOR_FONT: - font = fonts.get(key) - if font is None: - continue - if id(font) not in seen: - seen[id(font)] = key - continue - path, size = getattr(font, "path", None), getattr(font, "size", None) - if not path or not size: - continue - try: - fonts[key] = _IF.truetype(path, size) - except (OSError, ValueError, TypeError): - self.logger.debug( - "Could not un-share the %s face; it keeps the default colour", key) - return fonts - - def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): - """Colour for whichever element owns this face. - - Matched on identity, and deliberately gives up when one object is - shared: the last-resort font path can hand the same face to several - keys, and there is no right answer for which element's colour that is. - White is what those draws used before, so ambiguity costs nothing. - """ - try: - fonts = getattr(self, "fonts", None) or {} - matches = [element for key, element in self._ELEMENT_FOR_FONT.items() - if fonts.get(key) is font] - if len(matches) == 1: - return self._element_color(matches[0], default) - except (AttributeError, TypeError): - pass - return default - - def _draw_text_with_outline( - self, draw, text, position, font, fill=None, outline_color=(0, 0, 0) - ): - """Draw text with a black outline for better readability.""" - # Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get - # anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying - # glyphs. 1-bit mode keeps strokes crisp. - # Defaults to the configured colour for whichever element owns - # this face rather than to white, so customization..text_color - # reaches every draw. The schema has offered those pickers all along - # and they only ever changed the font. An explicit fill still wins: - # the odds colours and the favourite-result score tint mean something - # the palette does not. - if fill is None: - fill = self._font_color(font) - draw.fontmode = "1" - x, y = position - for dx, dy in [ - (-1, -1), - (-1, 0), - (-1, 1), - (0, -1), - (0, 1), - (1, -1), - (1, 0), - (1, 1), - ]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - def _load_and_resize_logo( self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]: @@ -1642,14 +1041,6 @@ def _get_timezone(self): log=self.logger, ) - def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: - """Check if we should log a warning based on cooldown period.""" - current_time = time.time() - if current_time - self._last_warning_time > cooldown: - self._last_warning_time = current_time - return True - return False - # Which ranking block the badge reads. ESPN answers /rankings with more # than one block for several leagues, and the FIRST is not always a poll: # men's and women's college hockey front "NCAA Men's/Women's Hockey @@ -1922,58 +1313,6 @@ def _fetch_todays_games(self) -> Optional[Dict]: ) return None - def _get_weeks_data(self) -> Optional[Dict]: - """ - Get partial data for immediate display while background fetch is in progress. - This fetches current/recent games only for quick response. - """ - try: - # Fetch current week and next few days for immediate display - now = datetime.now(pytz.utc) - immediate_events = [] - - start_date = now - timedelta(days=self.schedule_lookback_days) - end_date = now + timedelta(days=self.schedule_lookahead_days) - date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - response = self.session.get( - url, - params={"dates": date_str, "limit": 1000}, - headers=self.headers, - timeout=10, - ) - response.raise_for_status() - data = response.json() - immediate_events = data.get("events", []) - - if immediate_events: - self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") - return {"events": immediate_events} - - except requests.exceptions.RequestException as e: - self.logger.warning( - f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}" - ) - return None - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - pass - - def cleanup(self): - """Clean up resources when plugin is unloaded.""" - # Close HTTP session - if hasattr(self, 'session') and self.session: - try: - self.session.close() - except Exception as e: - self.logger.warning(f"Error closing session: {e}") - - # Clear caches - if hasattr(self, '_logo_cache'): - self._logo_cache.clear() - - self.logger.info(f"{self.__class__.__name__} cleanup completed") - def _is_favorite_game(self, game: Dict) -> bool: """Does either side of this game belong to a favourite team?""" @@ -2084,64 +1423,6 @@ def _load_division_team_ids(self) -> Dict[str, set]: self._division_team_ids[name] = ids return self._division_team_ids - def _game_divisions(self, game: Dict) -> Optional[set]: - """Divisions of BOTH sides, or None when they cannot be told. - - Both sides are collected, but the caller only needs ONE of them to sit - in a checked division. Requiring every participant read as "FBS games - only" and removed a ranked side hosting an FCS school -- which is still - a game involving a team the viewer checked the box for, and on a real - Week 2 slate it silently dropped five of the twenty ranked matchups. - What the checkbox is for is keeping FCS-versus-FCS out of a board - configured for FBS, and that still holds: a game with no checked - division on either side is dropped. - """ - divisions = self._load_division_team_ids() - if not any(divisions.values()): - return None - try: - ids = [int(game.get("home_id")), int(game.get("away_id"))] - except (TypeError, ValueError): - return None - present = set() - for team_id in ids: - for name in ("fbs", "fcs"): - if team_id in divisions.get(name, set()): - present.add(name) - break - else: - present.add("other") - return present - - def _league_has_rankings(self) -> bool: - """Only college leagues publish a poll; everyone else 404s. - - This gate matters more than it looks. _fetch_team_rankings only - short-circuits when the cache is non-empty, so a failed fetch leaves it - empty and the next update tries again -- at a 30s interval that is - ~2,900 pointless requests a day, per league, all of them 404s. - """ - league = (self.league or "").lower() - return "college" in league or "ncaa" in league - - @staticmethod - def _normalise_divisions(raw) -> List[str]: - """Division names from config, in the shape the filter expects. - - A hand-edited config can hold "fbs" where the schema says ["fbs"], and - list("fbs") is ['f', 'b', 's'] -- three names that match no division, so - every non-favourite game is rejected by a setting the user believes says - the opposite. An empty list is left empty: that means "no division - filter" and is a legitimate choice, not a mistake to correct. - """ - if isinstance(raw, str): - raw = [raw] - try: - items = list(raw or []) - except TypeError: - return [] - return [str(d).strip().lower() for d in items if str(d).strip()] - def _setting_int(self, key: str, default: int, low: int, high: int) -> int: """A count from config, clamped to the range its schema declares. @@ -2179,64 +1460,6 @@ def _best_rank(self, game: Dict) -> int: rankings.get(game.get("away_abbr"), 0)) if r] return min(ranked) if ranked else 99 - def _round_robin_favorites(self, games: List[Dict], limit: int) -> List[Dict]: - """Each favourite team's next game before any team's second one. - - Taking the soonest N favourite games spends the slots on whoever plays - most often. Walked across a real season with two favourites and a limit - of 2, nine days of it showed Auburn twice and Georgia not at all -- - Auburn played either side of a Georgia bye, so both slots went to - Auburn. The other-games pool already refuses to do this; favourites - were still doing it. - - Depth is kept where there is room: one favourite with three slots still - gets its next three games, because the round-robin only comes back for - a team's second game once every team has had a first. - - A game between two favourites is picked once and counts for both. - """ - if limit <= 0 or not games: - return [] - wanted = [t for t in (self.favorite_teams or []) if t] - if len(wanted) < 2: - return games[:limit] # nothing to share the slots between - - # Which side of a game belongs to which favourite is a per-lineage - # question: NRL matches on ESPN team IDs because its abbreviations are - # not unique ("NEW" is both Newcastle and New Zealand), while the rest - # match on abbreviation. Ask for the lineage's own matcher rather than - # assuming, or this silently groups nothing and every slot goes empty. - team_in = getattr(self, "_team_in", None) - if callable(team_in): - def belongs(game, team): - return bool(team_in(game.get("home_id"), [team]) - or team_in(game.get("away_id"), [team])) - else: - def belongs(game, team): - return team in (game.get("home_abbr"), game.get("away_abbr")) - - queues = {team: [] for team in wanted} - for game in games: # already in kickoff order - for team in wanted: - if belongs(game, team): - queues[team].append(game) - - picked, taken = [], set() - while len(picked) < limit: - progressed = False - for team in wanted: - queue = queues[team] - while queue and queue[0].get("id") in taken: - queue.pop(0) - if queue and len(picked) < limit: - game = queue.pop(0) - taken.add(game.get("id")) - picked.append(game) - progressed = True - if not progressed: - break # every queue is empty - return picked - def _by_importance(self, games: List[Dict], newest_first: bool = False) -> List[Dict]: """Non-favourite games, best matchup first. @@ -2301,35 +1524,6 @@ def key(game): #: migrates to "ranked" -- see _normalise_quality. _QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"}) - def _normalise_quality(self, raw) -> str: - """other_games_min_quality, as one of the values the code implements. - - An unusable value used to fall through every branch of - _passes_other_filters and silently mean "any" -- a quality bar the - board believes it has and does not. - """ - value = str(raw or "").strip().lower() - if value in self._QUALITY_CHOICES: - return value - if value == "broadcast": - # Retired in football-scoreboard 3.0.0 and now here. Measured - # against a real Week 1 and Week 2 college slate it passed 174 of - # 175 games: ESPN publishes a broadcaster for nearly everything - # now, ESPN+ included, so the tier read as a quality bar and - # behaved as "any". Boards holding it get the bar they thought - # they were getting. - self.logger.warning( - "%s: other_games_min_quality 'broadcast' has been retired -- " - "it let through nearly every game -- using 'ranked'. Change " - "the setting to clear this.", getattr(self, "sport_key", "?"), - ) - return "ranked" - self.logger.warning( - "%s: ignoring unusable other_games_min_quality=%r, using 'ranked'", - getattr(self, "sport_key", "?"), raw, - ) - return "ranked" - def _passes_other_filters(self, game: Dict) -> bool: """Is this non-favourite game worth one of the remaining slots? @@ -2369,44 +1563,6 @@ def _filtered_or_all(self, games: List[Dict]) -> List[Dict]: return kept or games - def _check_ranking_coverage(self, games: List[Dict]) -> None: - """Say so when a loaded poll matches nothing on the schedule. - - The table is keyed by the abbreviation the RANKINGS endpoint returns and - matched against the one the SCOREBOARD endpoint returns. Nothing - guarantees the two agree, and if they ever stop agreeing the filter - quietly removes every non-favourite game -- no exception, no log line, - just a shorter board. That is the same shape as the bug where rankings - were never loading at all, which survived until someone went looking. - - Throttled to once an hour: selection runs on every update. - """ - if self.other_games_min_quality != "ranked": - return - rankings = getattr(self, "_team_rankings_cache", None) or {} - if not rankings or not games: - return - if any(self._is_ranked_game(g) for g in games): - return - now = time.monotonic() - # Zero means never logged, not "logged at the epoch". monotonic() counts - # from an arbitrary origin -- on a freshly booted board it is a few - # hundred seconds -- so comparing against 0 swallowed the first warning - # for the first hour of uptime, which is exactly when a misconfigured - # board is being watched. CI caught this; a machine with days of uptime - # cannot. - if (self._ranking_coverage_logged_at - and now - self._ranking_coverage_logged_at < self._RANKING_COVERAGE_SECONDS): - return - self._ranking_coverage_logged_at = now - self.logger.warning( - "%s: %d ranked teams loaded, but none of the %d other games match " - "one -- the quality filter is removing every non-favourite game. " - "Ranked abbreviations look like: %s", - self.league, len(rankings), len(games), - ", ".join(sorted(rankings)[:8]), - ) - def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: """A rotating slice of the non-favourite games. @@ -2445,98 +1601,6 @@ def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: window += others[:limit - len(window)] return window - def _favorites_first( - self, - processed_games: List[Dict], - favorite_limit: int, - other_limit: int, - newest_first: bool = False, - ) -> List[Dict]: - """Favourite games first, then a bounded number of everything else. - - This is the middle setting the plugin was missing. `show_favorite_teams_only` - used to be the whole story: on, and you saw nothing but your teams; off, - and your teams were ignored entirely -- the selection just took the next - N games league-wide, so a UGA fan with 946 upcoming college games in the - window saw UGA about as often as chance allowed. - - Both counts are TOTALS here, not per-team. In favourites-only mode - `upcoming_games_to_show` is a per-team budget, which is reasonable when - the list is your own teams; applied to a dynamic group it is not. With - AP_TOP_10 resolving to a dozen teams, three games each is 28 distinct - cards before a single non-favourite is added. A total keeps the rotation - the length the user asked for. - """ - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key, reverse=True) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key) - - favorites, others, unfiltered = [], [], [] - for game in ordered: - if self._is_favorite_game(game): - favorites.append(game) # never filtered: your team is your team - continue - unfiltered.append(game) - if self._passes_other_filters(game): - others.append(game) - self._check_ranking_coverage(unfiltered) - - self._selection_pools = { - "favorites": favorites, - "others": self._by_importance(others, newest_first), - "unfiltered": self._by_importance(unfiltered, newest_first), - "favorite_limit": favorite_limit, - "other_limit": other_limit, - "newest_first": newest_first, - } - return self._compose_selection() - - def _compose_selection(self) -> List[Dict]: - """Favourites plus the current slice of others, in schedule order. - - Split out of _favorites_first so the slice can be re-cut between - fetches. The pools are settled -- which games exist, and which of them - are worth a slot -- while WHICH of the others is on screen is a display - decision, and gating it on the fetch made the rotation interval a lie: - update() returns early until upcoming_update_interval has passed, so a - four-minute rotation actually stepped fifteen windows once an hour. - Same lesson as _advance_live_game_if_due further down this file. - """ - pools = self._selection_pools - favorites, others = pools["favorites"], pools["others"] - favorite_limit, other_limit = pools["favorite_limit"], pools["other_limit"] - newest_first = pools["newest_first"] - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - - selected = self._round_robin_favorites(favorites, max(0, favorite_limit)) - selected.extend(self._other_games_window(others, max(0, other_limit))) - if not selected and other_limit > 0: - # Nothing survived at all: your teams are not playing inside the - # schedule window AND the filters removed every other game. Each - # check fails open on missing data, but a filter working exactly as - # asked can still match nothing on a given day, and with no - # favourite game left there is nothing to carry the mode -- an empty - # list is a blank panel, not a short one. Same whole-list fallback - # `_filtered_or_all` makes for a board with no favourites at all. - # `other_limit` of 0 is an explicit "favourites only", so that one - # is left to go quiet as asked. - selected = self._other_games_window(pools["unfiltered"], max(0, other_limit)) - # Re-sort so the card order still reads as a schedule. Selection decides - # WHICH games; it should not reorder them into favourites-then-others, - # which would show next week's UGA game before tonight's. - selected.sort(key=key, reverse=newest_first) - return selected - def _rotate_other_games_on_display(self) -> bool: """Swap in a freshly cut slice when the rotation interval has passed. @@ -2621,8 +1685,6 @@ def __init__( self.game_display_duration = 15 # Display each upcoming game for 15 seconds - - def _select_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] ) -> List[Dict]: @@ -3137,39 +2199,7 @@ def display(self, force_clear=False) -> bool: return False -class SportsRecent(SportsCore): - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mode_config.get( - "recent_update_interval", 3600 - ) # Check for recent games every hour - self.last_game_switch = 0 - self.game_display_duration = self.mode_config.get("recent_game_duration", 15) - self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 - - def _get_zero_clock_duration(self, game_id: str) -> float: - """Track how long a game has been at 0:00 clock.""" - current_time = time.time() - if game_id not in self._zero_clock_timestamps: - self._zero_clock_timestamps[game_id] = current_time - return 0.0 - return current_time - self._zero_clock_timestamps[game_id] - - def _clear_zero_clock_tracking(self, game_id: str) -> None: - """Clear tracking when game clock moves away from 0:00 or game ends.""" - if game_id in self._zero_clock_timestamps: - del self._zero_clock_timestamps[game_id] +class SportsRecent(SportsRecentSharedMixin, SportsCore): def _select_recent_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] @@ -3704,7 +2734,7 @@ def _swrr_schedule(weighted_ids: List[Tuple[str, int]]) -> List[str]: return order -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -3827,76 +2857,6 @@ def _is_game_really_over(self, game: Dict) -> bool: ) return False - def _detect_stale_games(self, games: List[Dict]) -> None: - """Remove games that appear stale or haven't updated.""" - current_time = time.time() - - for game in games[:]: # Copy list to iterate safely - game_id = game.get("id") - if not game_id: - continue - - # Check if game data is stale - timestamps = self.game_update_timestamps.get(game_id, {}) - last_seen = timestamps.get("last_seen", 0) - - if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: - self.logger.warning( - f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(last seen {int(current_time - last_seen)}s ago)" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - continue - - # Also check if game appears to be over - if self._is_game_really_over(game): - self.logger.debug( - f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - - def _idle_live_interval(self) -> int: - """How long to wait before looking for live games again, when there are none. - - Escalates the longer nothing turns up, and any live game resets it, so - an in-season gap between games costs at most one escalated wait while - an out-of-season league stops polling on a live cadence entirely. - - Capped rather than unbounded: the cost of backing off is how late the - first game after a quiet spell is noticed, and past the cap the saving - stops being worth that. - """ - streak = getattr(self, "_empty_live_streak", 0) - base = self.no_data_interval - ceiling = getattr(self, "live_idle_max_interval", - _DEFAULT_LIVE_IDLE_MAX_SECONDS) - # The ceiling bounds the un-escalated interval too. The two settings are - # independent integers with no cross-validation, so base > ceiling is a - # reachable config -- and returning base unclamped there made the wait - # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), - # the opposite of what the setting named "maximum" promises. - if streak >= _IDLE_LONG_STREAK: - return min(int(base * _IDLE_LONG_FACTOR), ceiling) - if streak >= _IDLE_SHORT_STREAK: - return min(int(base * _IDLE_SHORT_FACTOR), ceiling) - return min(base, ceiling) - - def _note_live_fetch(self, found_live: bool) -> None: - """Record whether a look for live games found any.""" - if found_live: - if getattr(self, "_empty_live_streak", 0): - self.logger.info( - "Live games found after %d empty check(s); back to the " - "live update interval", self._empty_live_streak) - self._empty_live_streak = 0 - else: - self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 - def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: diff --git a/plugins/lacrosse-scoreboard/test_favorite_live_boost.py b/plugins/lacrosse-scoreboard/test_favorite_live_boost.py index e5c57485..0fff52da 100644 --- a/plugins/lacrosse-scoreboard/test_favorite_live_boost.py +++ b/plugins/lacrosse-scoreboard/test_favorite_live_boost.py @@ -15,6 +15,7 @@ Run: /bin/python plugins/lacrosse-scoreboard/test_favorite_live_boost.py """ +import os import sys import threading import types @@ -43,6 +44,23 @@ def mod(name, **attrs): mod("src.plugin_system.base_plugin", BasePlugin=None, VegasDisplayMode=object) mod("src.background_data_service", get_background_service=lambda *a, **k: None) + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + _stub_core_src() diff --git a/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py b/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py index aef8323e..f8368bfe 100644 --- a/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py +++ b/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py @@ -25,6 +25,7 @@ import json import logging +import os import sys import types import urllib.error @@ -69,6 +70,23 @@ def _install_host_stubs() -> None: "SportsScrollDisplayManager", (object,), {}) sys.modules["src.api_counter"].increment_api_counter = lambda *a, **k: None + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + _install_host_stubs() diff --git a/plugins/lacrosse-scoreboard/test_non_favorite_live_duration.py b/plugins/lacrosse-scoreboard/test_non_favorite_live_duration.py index eb6d8122..42c09245 100644 --- a/plugins/lacrosse-scoreboard/test_non_favorite_live_duration.py +++ b/plugins/lacrosse-scoreboard/test_non_favorite_live_duration.py @@ -9,6 +9,7 @@ Run: /bin/python plugins/lacrosse-scoreboard/test_non_favorite_live_duration.py """ +import os import sys import types from pathlib import Path @@ -33,6 +34,23 @@ def mod(name, **attrs): mod("src.plugin_system.base_plugin", BasePlugin=None, VegasDisplayMode=object) mod("src.background_data_service", get_background_service=lambda *a, **k: None) + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + _stub_core_src() diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 764c4787..6ab6603e 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.18.1", + "version": "1.20.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,13 @@ "nrl_upcoming" ], "versions": [ + { + "version": "1.20.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "The sports.py logic every scoreboard shares moves to the core. Forty-five method bodies here were byte-identical to the same forty-five in every other scoreboard -- the selection and rotation engine, the font, colour and date subsystem, and the switch-mode upcoming card -- so they now come from src.common.sports_shared and this plugin inherits them. 1040 lines removed here. A fix to any of that now reaches every scoreboard at once instead of needing eight identical edits. Three deliberately stayed behind: _get_timezone, because it binds a per-plugin timezone module whose contents differ, and the two abstract stubs that define what makes this sport its own. Nothing drawn changes -- the bodies moved rather than being rewritten, and all 176 safety-harness renders across the eight plugins are byte-identical to before. The floor rises to 3.3.0, the release that first ships the shared module.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.18.1", "released": "2026-09-02", @@ -301,6 +308,6 @@ "class_name": "NrlScoreboardPlugin", "config_schema": "config_schema.json", "compatible_versions": [ - ">=3.2.0" + ">=3.3.0" ] } diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index 288be22f..3c963bfb 100644 --- a/plugins/nrl-scoreboard/sports.py +++ b/plugins/nrl-scoreboard/sports.py @@ -40,6 +40,8 @@ if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) from src.logo_downloader import LogoDownloader, download_missing_logo +from src.common.sports_shared import ( + SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin) def _resolve_font_path(path: str) -> str: @@ -85,7 +87,6 @@ def _resolve_font_path(path: str) -> str: return path - _DEFAULT_LOOKBACK_DAYS = 14 _DEFAULT_LOOKAHEAD_DAYS = 7 _MIN_WINDOW_DAYS = 1 @@ -154,7 +155,7 @@ def _logo_needs_refresh(logo_file) -> bool: return False -class SportsCore(ABC): +class SportsCore(SportsCoreSharedMixin, ABC): def __init__( self, config: Dict[str, Any], @@ -513,24 +514,6 @@ def _get_logo_directory_fallbacks(self, configured_dir: Path) -> List[Path]: return unique_fallbacks - def _get_season_schedule_dates(self) -> tuple[str, str]: - return "", "" - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Placeholder draw method - subclasses should override.""" - # This base method will be simple, subclasses provide specifics - try: - img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - status = game.get("status_text", "N/A") - self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) - self.display_manager.image.paste(img, (0, 0)) - # Don't call update_display here, let subclasses handle it after drawing - except Exception as e: - self.logger.error( - f"Error in base _draw_scorebug_layout: {e}", exc_info=True - ) - def display(self, force_clear: bool = False) -> bool: """Render the current game. Returns False when nothing can be shown.""" if not self.is_enabled: # Check if module is enabled @@ -578,62 +561,6 @@ def display(self, force_clear: bool = False) -> bool: 'four_by_six': '4x6-font.ttf', } - @classmethod - def _crisp_size(cls, font_file, desired): - """Snap *desired* to the nearest size *font_file* renders crisply at. - - A face with no known grid is returned unchanged, so a user-supplied - font is never second-guessed. - """ - font_file = cls._FONT_NAME_ALIASES.get(font_file, font_file) - grid = cls._FONT_PIXEL_GRID.get(font_file) - if not grid or not desired or desired <= 0: - return desired - return max(grid, int(round(float(desired) / grid)) * grid) - - def _schema_font_size(self, element_key): - """The font_size this plugin's config_schema.json declares, or None.""" - if not element_key: - return None - cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None) - if cache is None: - cache = {} - try: - import json - schema_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'config_schema.json') - with open(schema_path) as fh: - schema = json.load(fh) - props = (schema.get('properties', {}) - .get('customization', {}) - .get('properties', {})) - for key, spec in props.items(): - size = spec.get('properties', {}).get('font_size', {}).get('default') - if size is not None: - cache[key] = int(size) - except Exception: - cache = {} - self.__class__._SCHEMA_FONT_SIZES = cache - return cache.get(element_key) - - def _resolve_font_size(self, element_config, element_key, default_size, font_name): - """Size to render at: the user's choice, or a grid-snapped default. - - A configured size counts as a real choice only when it differs from - the schema default. The web UI writes the whole schema default block - on every save, so "font_size == schema default" carries no intent and - would otherwise pin every install to an anti-aliased size forever. - """ - configured = (element_config or {}).get('font_size') - if configured is not None: - try: - configured = int(configured) - if configured != self._schema_font_size(element_key): - return configured - except (TypeError, ValueError): - pass - return self._crisp_size(font_name, default_size) - def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], default_size: int = 8, element_key=None, default_font: Optional[str] = None) -> ImageFont.FreeTypeFont: """ Load a custom font from an element configuration dictionary. @@ -727,124 +654,6 @@ def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", ) - def _card_option(self, key: str, default: Any = None) -> Any: - """Read one key from the scroll_card config block.""" - block = (self.config or {}).get("scroll_card") - if isinstance(block, dict) and block.get(key) is not None: - return block.get(key) - return default - - def _switch_upcoming_center(self) -> str: - """Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'.""" - mode = str(self._card_option("switch_upcoming_center", "date_time") - or "date_time").lower() - if mode == "inherit": - mode = str(self._card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time", "none") else "date_time" - - def _vs_text(self) -> str: - """Separator drawn between the teams -- "VS", "@", "at", anything.""" - return str(self._card_option("vs_text", "VS")) - - def _switch_date_format(self) -> str: - """Date style for the full-screen scorebug. - - Its own key rather than the shared ``date_format`` because the two - displays disagree about the default: the scroll card renders "Sep 19" - while _extract_game_details_common emits "9/19", the "numeric" style, - and this scorebug has always drawn it. Reading the shared key here - would restyle every existing panel on update -- and "leave it alone - when unset" is not available, because the core merges schema defaults - into the config on every load, so the key is never actually unset. - "inherit" opts into the scroll and Vegas setting. - """ - fmt = str(self._card_option("switch_date_format", "numeric") or "numeric").lower() - if fmt == "inherit": - fmt = str(self._card_option("date_format", "abbrev") or "abbrev").lower() - return fmt - - def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: - """Format an upcoming date per scroll_card.switch_date_format.""" - raw = str(date_text or "").strip() - if not raw: - return raw - fmt = self._switch_date_format() - if fmt == "numeric": - return raw - parts = raw.replace("-", "/").split("/") - if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): - return raw - month, day = int(parts[0]), int(parts[1]) - if not 1 <= month <= 12: - return raw - name = self._MONTH_ABBR[month - 1] - if fmt == "numeric_day_first": - return f"{day}/{month}" - if fmt == "day_first": - return f"{day} {name}" - if fmt == "weekday": - weekday = self._weekday_for(game) - return f"{weekday} {name} {day}" if weekday else f"{name} {day}" - return f"{name} {day}" - - def _weekday_for(self, game: Optional[Dict]) -> str: - """Weekday abbreviation from the game's start time, or ''.""" - if not game: - return "" - raw = game.get("start_time_utc") or game.get("start_time") - if not raw: - return "" - try: - start = raw if isinstance(raw, datetime) else datetime.fromisoformat( - str(raw).replace("Z", "+00:00")) - return self._WEEKDAY_ABBR[start.astimezone(self._get_timezone()).weekday()] - except (ValueError, TypeError, OverflowError): - return "" - - def _format_game_time(self, time_text: str) -> str: - """Return the time as-is (12h) or converted to 24h.""" - raw = str(time_text or "").strip() - if not raw or str(self._card_option("time_format", "12h")) != "24h": - return raw - cleaned = raw.upper().replace(" ", "") - meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" - if not meridiem: - return raw - try: - hh, _, mm = cleaned[:-2].partition(":") - hour, minute = int(hh), int(mm or 0) - except ValueError: - return raw - if not (0 <= hour <= 12 and 0 <= minute <= 59): - return raw - hour = hour % 12 + (12 if meridiem == "PM" else 0) - return f"{hour:02d}:{minute:02d}" - - def _scorebug_font(self, draw, text: str, width: int): - """The face this scorebug draws its date and time in. - - Always the "time" face, which is what this display has used for both - rows for as long as it has existed: changing switch_upcoming_center - moves the two lines around, it is not meant to restyle them, so the - type stays put while the placement changes. - - The single exception is text that cannot fit the panel at all. Only - the "weekday" date can do that -- "Fri Sep 19" measures 80px in an - 8px face, on a board 64px wide -- and the smaller "detail" face is a - better answer there than running off both edges. Every other date and - time this display can produce fits, so in practice the face never - changes; it is a floor, not a style rule. - """ - font = self.fonts["time"] - if not text: - return font - try: - if draw.textlength(text, font=font) + 2 <= width: - return font - except (TypeError, ValueError): - return font - return self.fonts.get("detail") or font - def _upcoming_date_and_time_text(self, game_date: str, game_time: str, game: Optional[Dict] = None) -> Tuple[str, str]: """The formatted (date, time) pair, blanked by show_date/show_time.""" @@ -854,106 +663,6 @@ def _upcoming_date_and_time_text(self, game_date: str, game_time: str, if self._card_option("show_time", True) else "") return date_text, time_text - def _draw_upcoming_center_switch(self, draw, game: Dict, center_y: int, - game_date: str, game_time: str, - display_width: Optional[int] = None, - display_height: Optional[int] = None, - date_element: str = 'date', - time_element: str = 'time', - second_row_y_offset: bool = True) -> bool: - """Draw the middle of the full-screen upcoming scorebug. - - Returns True when the header above it ("Next Game", or the league - name) should still be drawn. In "vs" and "none" the date and time move - out of the middle and into the top and bottom slots, mirroring the - scroll card -- and the top slot is where the header used to be, so the - caller drops it. - - ``date_element``/``time_element``/``second_row_y_offset`` exist only so - the layout-offset keys stay exactly what each plugin's schema - advertises; this sport's defaults are the common case. - """ - width = self.display_width if display_width is None else display_width - height = self.display_height if display_height is None else display_height - mode = self._switch_upcoming_center() - date_text, time_text = self._upcoming_date_and_time_text( - game_date, game_time, game) - swapped = bool(self._card_option("swap_date_time", False)) - - if mode == "date_time": - # Historically the date sat at center_y - 7 with the time 9px - # under it, and the time's row was derived from the date's, so a - # date y_offset moved the pair. Both still hold; the slots only - # trade places when swap_date_time is set, and hiding one line - # leaves the other where it was rather than re-centering the stack. - slots = [(time_element, time_text), (date_element, date_text)] if swapped \ - else [(date_element, date_text), (time_element, time_text)] - row_y = center_y - 7 - for index, (element, text) in enumerate(slots): - if index: - row_y += 9 - if second_row_y_offset: - row_y += self._get_layout_offset(element, 'y_offset') - else: - row_y += self._get_layout_offset(element, 'y_offset') - if not text: - continue - font = self._scorebug_font(draw, text, width) - text_width = draw.textlength(text, font=font) - text_x = ((width - text_width) // 2 - + self._get_layout_offset(element, 'x_offset')) - self._draw_text_with_outline( - draw, text, (text_x, row_y), font - ) - return True - - if mode == "vs": - vs_text = self._vs_text() - if vs_text: - vs_width = draw.textlength(vs_text, font=self.fonts["score"]) - vs_x = ((width - vs_width) // 2 - + self._get_layout_offset('score', 'x_offset')) - vs_y = (center_y - 3 - + self._get_layout_offset('score', 'y_offset')) - self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts["score"] - ) - - # "vs" and "none" both push the date and time out to the edges, time - # on top unless swap_date_time says otherwise -- the same order the - # scroll card uses. - if swapped: - top_element, top_text = date_element, date_text - bottom_element, bottom_text = time_element, time_text - else: - top_element, top_text = time_element, time_text - bottom_element, bottom_text = date_element, date_text - - if top_text: - top_font = self._scorebug_font(draw, top_text, width) - top_width = draw.textlength(top_text, font=top_font) - top_x = ((width - top_width) // 2 - + self._get_layout_offset(top_element, 'x_offset')) - top_y = 1 + self._get_layout_offset(top_element, 'y_offset') - self._draw_text_with_outline( - draw, top_text, (top_x, top_y), top_font - ) - if bottom_text: - bottom_font = self._scorebug_font(draw, bottom_text, width) - bottom_width = draw.textlength(bottom_text, font=bottom_font) - bottom_x = ((width - bottom_width) // 2 - + self._get_layout_offset(bottom_element, 'x_offset')) - # Measured, not a fixed offset: the detail font is 6px in most - # plugins and 10px in soccer and nrl, where a fixed -7 ran the - # date off the panel. - ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] - bottom_y = (max(0, height - ink_bottom - 1) - + self._get_layout_offset(bottom_element, 'y_offset')) - self._draw_text_with_outline( - draw, bottom_text, (bottom_x, bottom_y), bottom_font - ) - return False - def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: """ Get layout offset for a specific element and axis. @@ -1002,86 +711,6 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: "tie": (255, 200, 0), } - @staticmethod - def _coerce_rgb(value, fallback): - """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" - # Checked before unpacking: a 3-character string ("123") would otherwise - # iterate into three digits and yield a colour rather than the fallback. - if not isinstance(value, (list, tuple)) or len(value) != 3: - return fallback - try: - r, g, b = (max(0, min(255, int(channel))) for channel in value) - except (TypeError, ValueError): - return fallback - return (r, g, b) - - @staticmethod - def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: - """Is the home/away side of this game a favorite team? - - Both the abbreviation and the ESPN id are checked, because a couple of - leagues (NRL) match favorites by id where abbreviations collide. - """ - for key in (f"{side}_abbr", f"{side}_id"): - value = game.get(key) - if value is not None and str(value).strip().upper() in favorites: - return True - return False - - def _favorite_result(self, game: Dict) -> Optional[str]: - """Say how the favorite team did in a finished game. - - Returns 'win', 'loss' or 'tie', or None when there is no single team - to root for: no favorites configured, neither side is a favorite, or - *both* are -- a favorite-vs-favorite game has no losing side worth - flagging in red. Also None when the scores are not usable numbers. - """ - favorites = getattr(self, "favorite_teams", None) or [] - favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} - if not favorites: - return None - - home_fav = self._side_is_favorite(game, "home", favorites) - away_fav = self._side_is_favorite(game, "away", favorites) - if home_fav == away_fav: - return None - - try: - # int(float(...)) to match GameRenderer._side_score exactly -- the - # two paths must agree on what counts as a usable score. - home_score = int(float(str(game.get("home_score", "")).strip())) - away_score = int(float(str(game.get("away_score", "")).strip())) - except (TypeError, ValueError): - return None - - if home_score == away_score: - return "tie" - favorite_score, other_score = ( - (home_score, away_score) if home_fav else (away_score, home_score) - ) - return "win" if favorite_score > other_score else "loss" - - def _recent_score_color(self, game: Dict, default): - """Fill color for a finished game's score, per favorite_result_colors.""" - try: - settings = (self.config.get("customization") or {}).get( - "favorite_result_colors" - ) or {} - if not settings.get("enabled", False): - return default - result = self._favorite_result(game) - if result is None: - return default - return self._coerce_rgb( - settings.get(f"{result}_color"), - self.FAVORITE_RESULT_COLOR_DEFAULTS[result], - ) - except Exception: - self.logger.debug( - "Could not resolve favorite result color", exc_info=True - ) - return default - #: How far each logo is shifted outward, off the panel edge, by the #: scorebug layouts (they paste at -2 and width - logo_width + 2). Kept #: here because the logo sizing has to know it. @@ -1229,137 +858,6 @@ def _fit_score_font(self, fonts): #: -- so on a taller panel they grew and the score did not. _FONT_DESIGN_HEIGHT: ClassVar[int] = 32 - def _score_font_size(self) -> int: - """Pixel size the score is currently drawn at.""" - return getattr(self.fonts.get("score"), "size", 8) or 8 - - def _time_font_size(self) -> int: - """Pixel size the clock/date face is currently drawn at.""" - return getattr(self.fonts.get("time"), "size", 8) or 8 - - def _user_chose_size(self, element_key: str) -> bool: - """True when customization..font_size is a real choice. - - The web UI's save flow writes the whole schema default block into - config.json on every save, whether or not the user touched that - section, so a size merely being PRESENT carries no intent. Only one - that differs from the schema default does. - """ - element = (self.config.get('customization', {}) or {}).get(element_key) or {} - configured = element.get('font_size') - if configured is None: - return False - try: - return int(configured) != self._schema_font_size(element_key) - except (TypeError, ValueError): - return False - - def _grid_scaled_size(self, font): - """(path, grid, size) for *font* regrown to this panel's height. - - None when the panel is at or below the design height (nothing to do), - or when the face has no known pixel grid -- a user-supplied font is - never second-guessed, because we do not know what it renders crisply - at. - """ - path = getattr(font, 'path', None) - base = getattr(font, 'size', None) - if not base or not isinstance(path, str): - return None - face = os.path.basename(path) - grid = self._FONT_PIXEL_GRID.get(self._FONT_NAME_ALIASES.get(face, face)) - if not grid: - return None - scale = float(self.display_height) / (self._FONT_DESIGN_HEIGHT or 32) - if scale <= 1.0: - return None - return path, grid, max(int(base), int(self._crisp_size(face, base * scale))) - - def _scale_headline_fonts(self, fonts): - """Grow the score with the panel, and hold the clock/date below it. - - The score is the one number the card exists to show, and it was the - only element not sized from the panel. Worse, it was not even bigger - than its neighbours: PressStart2P renders crisply on an 8px grid, so - the 10px default snapped to 8 -- the same 8 the period/clock above it - and the game date below it are drawn at. Three lines of identical - type, none of them the headline, which is what makes the score read as - lower priority than the time and the date rather than the point of the - card. - - So the score is sized from display_height and snapped to its face's - pixel grid (off the grid FreeType anti-aliases the strokes, and on an - LED matrix a part-lit pixel is a dim lamp rather than a soft edge), - then stepped back down that grid until it fits its share of the width. - The clock/date face is regrown the same way but held at least one grid - step below the score, so the ranking between them is visible rather - than implied. - - A 32-tall panel scales by exactly 1.0 and is left byte-identical; a - size the user set explicitly is never overridden. - """ - self._score_grew = False - if not self._DRAWS_SCORE: - # No score on this screen, so none of the sizing below is for it. - return fonts - try: - scaled = None if self._user_chose_size('score_text') else \ - self._grid_scaled_size(fonts.get('score')) - if scaled is not None: - path, grid, size = scaled - base = getattr(fonts['score'], 'size', size) or size - size = min(size, base * self._SCORE_MAX_GROWTH) - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - # Measured from a fixed five-character score rather than the - # live one, so the card does not resize when a side passes 9. - while size > grid: - if probe.textlength( - self._SCORE_PROBE_TEXT, - font=ImageFont.truetype(path, size)) <= budget: - break - size -= grid - if size != getattr(fonts['score'], 'size', size): - fonts['score'] = ImageFont.truetype(path, size) - self._score_grew = True - - if not self._score_grew and not self._user_chose_size('score_text') \ - and self.display_height > self._FONT_DESIGN_HEIGHT: - # PressStart2P could not grow inside the budget -- its next crisp - # size is simply too wide for this panel. A narrower face still - # can: 4x6-font at 14px is nearly as tall as PressStart2P at 16 - # and about half as wide. This matters beyond the score itself, - # because a card whose score never grows never reserves the - # centre either, so its logos stay at the uncapped 1.5x and are - # drawn straight over the score -- which is what a three-digit - # basketball score does on a 128x64 board. - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - current = getattr(fonts.get('score'), 'size', 0) or 0 - for _name, _size in self._NARROW_SCORE_RUNGS: - if _size <= current: - continue - _path = _resolve_font_path(f"assets/fonts/{_name}") - _candidate = ImageFont.truetype(_path, _size) - if probe.textlength(self._SCORE_PROBE_TEXT, - font=_candidate) <= budget: - fonts['score'] = _candidate - self._score_grew = True - break - - scaled = None if self._user_chose_size('period_text') else \ - self._grid_scaled_size(fonts.get('time')) - if scaled is not None: - path, grid, size = scaled - ceiling = getattr(fonts.get('score'), 'size', 0) or 0 - if ceiling and size >= ceiling: - size = max(grid, ceiling - grid) - if size != getattr(fonts['time'], 'size', size): - fonts['time'] = ImageFont.truetype(path, size) - except Exception: - self.logger.debug("Headline font scaling skipped", exc_info=True) - return fonts - def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1549,105 +1047,6 @@ def _draw_dynamic_odds( "rank": "rank_text", } - def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): - """Per-element text colour from customization..text_color.""" - try: - cfg = (self.config or {}).get("customization", {}).get(element, {}) - value = cfg.get("text_color") - if isinstance(value, (list, tuple)) and len(value) == 3: - return tuple(max(0, min(255, int(c))) for c in value) - if isinstance(value, str) and value.startswith("#") and len(value) == 7: - return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) - except (TypeError, ValueError): - pass - return default - - def _unshare_element_fonts(self, fonts): - """Give each colourable element its own face object. - - The colour a draw gets is resolved from the face it was handed, and - several of these loaders legitimately hand one object to more than one - element -- a size resolver that lands two elements on the same face, a - fallback that fills every key from one default, football's narrowing - step that deliberately shrinks the clock along with the score. Sharing - the object makes the element ambiguous and the colour unresolvable. - - Re-instantiating from the same path and size gives a distinct object - with identical metrics, so nothing about the rendering changes; only - the ability to tell two elements apart does. Faces that cannot be - rebuilt (a BDF loaded through freetype.Face, anything without a usable - path) are left shared, and their draws stay white as before. - """ - try: - from PIL import ImageFont as _IF - except ImportError: # pragma: no cover - return fonts - seen = {} - for key in self._ELEMENT_FOR_FONT: - font = fonts.get(key) - if font is None: - continue - if id(font) not in seen: - seen[id(font)] = key - continue - path, size = getattr(font, "path", None), getattr(font, "size", None) - if not path or not size: - continue - try: - fonts[key] = _IF.truetype(path, size) - except (OSError, ValueError, TypeError): - self.logger.debug( - "Could not un-share the %s face; it keeps the default colour", key) - return fonts - - def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): - """Colour for whichever element owns this face. - - Matched on identity, and deliberately gives up when one object is - shared: the last-resort font path can hand the same face to several - keys, and there is no right answer for which element's colour that is. - White is what those draws used before, so ambiguity costs nothing. - """ - try: - fonts = getattr(self, "fonts", None) or {} - matches = [element for key, element in self._ELEMENT_FOR_FONT.items() - if fonts.get(key) is font] - if len(matches) == 1: - return self._element_color(matches[0], default) - except (AttributeError, TypeError): - pass - return default - - def _draw_text_with_outline( - self, draw, text, position, font, fill=None, outline_color=(0, 0, 0) - ): - """Draw text with a black outline for better readability.""" - # Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get - # anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying - # glyphs. 1-bit mode keeps strokes crisp. - # Defaults to the configured colour for whichever element owns - # this face rather than to white, so customization..text_color - # reaches every draw. The schema has offered those pickers all along - # and they only ever changed the font. An explicit fill still wins: - # the odds colours and the favourite-result score tint mean something - # the palette does not. - if fill is None: - fill = self._font_color(font) - draw.fontmode = "1" - x, y = position - for dx, dy in [ - (-1, -1), - (-1, 0), - (-1, 1), - (0, -1), - (0, 1), - (1, -1), - (1, 0), - (1, 1), - ]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - def _load_and_resize_logo( self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]: @@ -1796,14 +1195,6 @@ def _get_timezone(self): log=self.logger, ) - def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: - """Check if we should log a warning based on cooldown period.""" - current_time = time.time() - if current_time - self._last_warning_time > cooldown: - self._last_warning_time = current_time - return True - return False - # Which ranking block the badge reads. ESPN answers /rankings with more # than one block for several leagues, and the FIRST is not always a poll: # men's and women's college hockey front "NCAA Men's/Women's Hockey @@ -2182,43 +1573,6 @@ def _fetch_todays_games(self) -> Optional[Dict]: ) return None - def _get_weeks_data(self) -> Optional[Dict]: - """ - Get partial data for immediate display while background fetch is in progress. - This fetches current/recent games only for quick response. - """ - try: - # Fetch current week and next few days for immediate display - now = datetime.now(pytz.utc) - immediate_events = [] - - start_date = now - timedelta(days=self.schedule_lookback_days) - end_date = now + timedelta(days=self.schedule_lookahead_days) - date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - response = self.session.get( - url, - params={"dates": date_str, "limit": 1000}, - headers=self.headers, - timeout=10, - ) - response.raise_for_status() - data = response.json() - immediate_events = data.get("events", []) - - if immediate_events: - self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") - return {"events": immediate_events} - - except requests.exceptions.RequestException as e: - self.logger.warning( - f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}" - ) - return None - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - pass - def _get_team_record_text(self, abbr: str, record: str) -> str: """Return the corner text for a team: ranking (if enabled/available) or record. @@ -2233,21 +1587,6 @@ def _get_team_record_text(self, abbr: str, record: str) -> str: return record return "" - def cleanup(self): - """Clean up resources when plugin is unloaded.""" - # Close HTTP session - if hasattr(self, 'session') and self.session: - try: - self.session.close() - except Exception as e: - self.logger.warning(f"Error closing session: {e}") - - # Clear caches - if hasattr(self, '_logo_cache'): - self._logo_cache.clear() - - self.logger.info(f"{self.__class__.__name__} cleanup completed") - def _is_favorite_game(self, game: Dict) -> bool: return self._team_in(game.get("home_id"), self.favorite_teams) or self._team_in( game.get("away_id"), self.favorite_teams @@ -2353,64 +1692,6 @@ def _load_division_team_ids(self) -> Dict[str, set]: self._division_team_ids[name] = ids return self._division_team_ids - def _game_divisions(self, game: Dict) -> Optional[set]: - """Divisions of BOTH sides, or None when they cannot be told. - - Both sides are collected, but the caller only needs ONE of them to sit - in a checked division. Requiring every participant read as "FBS games - only" and removed a ranked side hosting an FCS school -- which is still - a game involving a team the viewer checked the box for, and on a real - Week 2 slate it silently dropped five of the twenty ranked matchups. - What the checkbox is for is keeping FCS-versus-FCS out of a board - configured for FBS, and that still holds: a game with no checked - division on either side is dropped. - """ - divisions = self._load_division_team_ids() - if not any(divisions.values()): - return None - try: - ids = [int(game.get("home_id")), int(game.get("away_id"))] - except (TypeError, ValueError): - return None - present = set() - for team_id in ids: - for name in ("fbs", "fcs"): - if team_id in divisions.get(name, set()): - present.add(name) - break - else: - present.add("other") - return present - - def _league_has_rankings(self) -> bool: - """Only college leagues publish a poll; everyone else 404s. - - This gate matters more than it looks. _fetch_team_rankings only - short-circuits when the cache is non-empty, so a failed fetch leaves it - empty and the next update tries again -- at a 30s interval that is - ~2,900 pointless requests a day, per league, all of them 404s. - """ - league = (self.league or "").lower() - return "college" in league or "ncaa" in league - - @staticmethod - def _normalise_divisions(raw) -> List[str]: - """Division names from config, in the shape the filter expects. - - A hand-edited config can hold "fbs" where the schema says ["fbs"], and - list("fbs") is ['f', 'b', 's'] -- three names that match no division, so - every non-favourite game is rejected by a setting the user believes says - the opposite. An empty list is left empty: that means "no division - filter" and is a legitimate choice, not a mistake to correct. - """ - if isinstance(raw, str): - raw = [raw] - try: - items = list(raw or []) - except TypeError: - return [] - return [str(d).strip().lower() for d in items if str(d).strip()] - def _setting_int(self, key: str, default: int, low: int, high: int) -> int: """A count from config, clamped to the range its schema declares. @@ -2448,64 +1729,6 @@ def _best_rank(self, game: Dict) -> int: rankings.get(game.get("away_abbr"), 0)) if r] return min(ranked) if ranked else 99 - def _round_robin_favorites(self, games: List[Dict], limit: int) -> List[Dict]: - """Each favourite team's next game before any team's second one. - - Taking the soonest N favourite games spends the slots on whoever plays - most often. Walked across a real season with two favourites and a limit - of 2, nine days of it showed Auburn twice and Georgia not at all -- - Auburn played either side of a Georgia bye, so both slots went to - Auburn. The other-games pool already refuses to do this; favourites - were still doing it. - - Depth is kept where there is room: one favourite with three slots still - gets its next three games, because the round-robin only comes back for - a team's second game once every team has had a first. - - A game between two favourites is picked once and counts for both. - """ - if limit <= 0 or not games: - return [] - wanted = [t for t in (self.favorite_teams or []) if t] - if len(wanted) < 2: - return games[:limit] # nothing to share the slots between - - # Which side of a game belongs to which favourite is a per-lineage - # question: NRL matches on ESPN team IDs because its abbreviations are - # not unique ("NEW" is both Newcastle and New Zealand), while the rest - # match on abbreviation. Ask for the lineage's own matcher rather than - # assuming, or this silently groups nothing and every slot goes empty. - team_in = getattr(self, "_team_in", None) - if callable(team_in): - def belongs(game, team): - return bool(team_in(game.get("home_id"), [team]) - or team_in(game.get("away_id"), [team])) - else: - def belongs(game, team): - return team in (game.get("home_abbr"), game.get("away_abbr")) - - queues = {team: [] for team in wanted} - for game in games: # already in kickoff order - for team in wanted: - if belongs(game, team): - queues[team].append(game) - - picked, taken = [], set() - while len(picked) < limit: - progressed = False - for team in wanted: - queue = queues[team] - while queue and queue[0].get("id") in taken: - queue.pop(0) - if queue and len(picked) < limit: - game = queue.pop(0) - taken.add(game.get("id")) - picked.append(game) - progressed = True - if not progressed: - break # every queue is empty - return picked - def _by_importance(self, games: List[Dict], newest_first: bool = False) -> List[Dict]: """Non-favourite games, best matchup first. @@ -2570,35 +1793,6 @@ def key(game): #: migrates to "ranked" -- see _normalise_quality. _QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"}) - def _normalise_quality(self, raw) -> str: - """other_games_min_quality, as one of the values the code implements. - - An unusable value used to fall through every branch of - _passes_other_filters and silently mean "any" -- a quality bar the - board believes it has and does not. - """ - value = str(raw or "").strip().lower() - if value in self._QUALITY_CHOICES: - return value - if value == "broadcast": - # Retired in football-scoreboard 3.0.0 and now here. Measured - # against a real Week 1 and Week 2 college slate it passed 174 of - # 175 games: ESPN publishes a broadcaster for nearly everything - # now, ESPN+ included, so the tier read as a quality bar and - # behaved as "any". Boards holding it get the bar they thought - # they were getting. - self.logger.warning( - "%s: other_games_min_quality 'broadcast' has been retired -- " - "it let through nearly every game -- using 'ranked'. Change " - "the setting to clear this.", getattr(self, "sport_key", "?"), - ) - return "ranked" - self.logger.warning( - "%s: ignoring unusable other_games_min_quality=%r, using 'ranked'", - getattr(self, "sport_key", "?"), raw, - ) - return "ranked" - def _passes_other_filters(self, game: Dict) -> bool: """Is this non-favourite game worth one of the remaining slots? @@ -2638,44 +1832,6 @@ def _filtered_or_all(self, games: List[Dict]) -> List[Dict]: return kept or games - def _check_ranking_coverage(self, games: List[Dict]) -> None: - """Say so when a loaded poll matches nothing on the schedule. - - The table is keyed by the abbreviation the RANKINGS endpoint returns and - matched against the one the SCOREBOARD endpoint returns. Nothing - guarantees the two agree, and if they ever stop agreeing the filter - quietly removes every non-favourite game -- no exception, no log line, - just a shorter board. That is the same shape as the bug where rankings - were never loading at all, which survived until someone went looking. - - Throttled to once an hour: selection runs on every update. - """ - if self.other_games_min_quality != "ranked": - return - rankings = getattr(self, "_team_rankings_cache", None) or {} - if not rankings or not games: - return - if any(self._is_ranked_game(g) for g in games): - return - now = time.monotonic() - # Zero means never logged, not "logged at the epoch". monotonic() counts - # from an arbitrary origin -- on a freshly booted board it is a few - # hundred seconds -- so comparing against 0 swallowed the first warning - # for the first hour of uptime, which is exactly when a misconfigured - # board is being watched. CI caught this; a machine with days of uptime - # cannot. - if (self._ranking_coverage_logged_at - and now - self._ranking_coverage_logged_at < self._RANKING_COVERAGE_SECONDS): - return - self._ranking_coverage_logged_at = now - self.logger.warning( - "%s: %d ranked teams loaded, but none of the %d other games match " - "one -- the quality filter is removing every non-favourite game. " - "Ranked abbreviations look like: %s", - self.league, len(rankings), len(games), - ", ".join(sorted(rankings)[:8]), - ) - def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: """A rotating slice of the non-favourite games. @@ -2714,98 +1870,6 @@ def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: window += others[:limit - len(window)] return window - def _favorites_first( - self, - processed_games: List[Dict], - favorite_limit: int, - other_limit: int, - newest_first: bool = False, - ) -> List[Dict]: - """Favourite games first, then a bounded number of everything else. - - This is the middle setting the plugin was missing. `show_favorite_teams_only` - used to be the whole story: on, and you saw nothing but your teams; off, - and your teams were ignored entirely -- the selection just took the next - N games league-wide, so a UGA fan with 946 upcoming college games in the - window saw UGA about as often as chance allowed. - - Both counts are TOTALS here, not per-team. In favourites-only mode - `upcoming_games_to_show` is a per-team budget, which is reasonable when - the list is your own teams; applied to a dynamic group it is not. With - AP_TOP_10 resolving to a dozen teams, three games each is 28 distinct - cards before a single non-favourite is added. A total keeps the rotation - the length the user asked for. - """ - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key, reverse=True) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key) - - favorites, others, unfiltered = [], [], [] - for game in ordered: - if self._is_favorite_game(game): - favorites.append(game) # never filtered: your team is your team - continue - unfiltered.append(game) - if self._passes_other_filters(game): - others.append(game) - self._check_ranking_coverage(unfiltered) - - self._selection_pools = { - "favorites": favorites, - "others": self._by_importance(others, newest_first), - "unfiltered": self._by_importance(unfiltered, newest_first), - "favorite_limit": favorite_limit, - "other_limit": other_limit, - "newest_first": newest_first, - } - return self._compose_selection() - - def _compose_selection(self) -> List[Dict]: - """Favourites plus the current slice of others, in schedule order. - - Split out of _favorites_first so the slice can be re-cut between - fetches. The pools are settled -- which games exist, and which of them - are worth a slot -- while WHICH of the others is on screen is a display - decision, and gating it on the fetch made the rotation interval a lie: - update() returns early until upcoming_update_interval has passed, so a - four-minute rotation actually stepped fifteen windows once an hour. - Same lesson as _advance_live_game_if_due further down this file. - """ - pools = self._selection_pools - favorites, others = pools["favorites"], pools["others"] - favorite_limit, other_limit = pools["favorite_limit"], pools["other_limit"] - newest_first = pools["newest_first"] - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - - selected = self._round_robin_favorites(favorites, max(0, favorite_limit)) - selected.extend(self._other_games_window(others, max(0, other_limit))) - if not selected and other_limit > 0: - # Nothing survived at all: your teams are not playing inside the - # schedule window AND the filters removed every other game. Each - # check fails open on missing data, but a filter working exactly as - # asked can still match nothing on a given day, and with no - # favourite game left there is nothing to carry the mode -- an empty - # list is a blank panel, not a short one. Same whole-list fallback - # `_filtered_or_all` makes for a board with no favourites at all. - # `other_limit` of 0 is an explicit "favourites only", so that one - # is left to go quiet as asked. - selected = self._other_games_window(pools["unfiltered"], max(0, other_limit)) - # Re-sort so the card order still reads as a schedule. Selection decides - # WHICH games; it should not reorder them into favourites-then-others, - # which would show next week's UGA game before tonight's. - selected.sort(key=key, reverse=newest_first) - return selected - def _rotate_other_games_on_display(self) -> bool: """Swap in a freshly cut slice when the rotation interval has passed. @@ -2890,8 +1954,6 @@ def __init__( self.game_display_duration = 15 # Display each upcoming game for 15 seconds - - def _select_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] ) -> List[Dict]: @@ -3427,39 +2489,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsRecent(SportsCore): - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mode_config.get( - "recent_update_interval", 3600 - ) # Check for recent games every hour - self.last_game_switch = 0 - self.game_display_duration = self.mode_config.get("recent_game_duration", 15) - self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 - - def _get_zero_clock_duration(self, game_id: str) -> float: - """Track how long a game has been at 0:00 clock.""" - current_time = time.time() - if game_id not in self._zero_clock_timestamps: - self._zero_clock_timestamps[game_id] = current_time - return 0.0 - return current_time - self._zero_clock_timestamps[game_id] - - def _clear_zero_clock_tracking(self, game_id: str) -> None: - """Clear tracking when game clock moves away from 0:00 or game ends.""" - if game_id in self._zero_clock_timestamps: - del self._zero_clock_timestamps[game_id] +class SportsRecent(SportsRecentSharedMixin, SportsCore): def _select_recent_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] @@ -4020,7 +3050,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -4550,76 +3580,6 @@ def _is_game_really_over(self, game: Dict) -> bool: ) return False - def _detect_stale_games(self, games: List[Dict]) -> None: - """Remove games that appear stale or haven't updated.""" - current_time = time.time() - - for game in games[:]: # Copy list to iterate safely - game_id = game.get("id") - if not game_id: - continue - - # Check if game data is stale - timestamps = self.game_update_timestamps.get(game_id, {}) - last_seen = timestamps.get("last_seen", 0) - - if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: - self.logger.warning( - f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(last seen {int(current_time - last_seen)}s ago)" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - continue - - # Also check if game appears to be over - if self._is_game_really_over(game): - self.logger.debug( - f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - - def _idle_live_interval(self) -> int: - """How long to wait before looking for live games again, when there are none. - - Escalates the longer nothing turns up, and any live game resets it, so - an in-season gap between games costs at most one escalated wait while - an out-of-season league stops polling on a live cadence entirely. - - Capped rather than unbounded: the cost of backing off is how late the - first game after a quiet spell is noticed, and past the cap the saving - stops being worth that. - """ - streak = getattr(self, "_empty_live_streak", 0) - base = self.no_data_interval - ceiling = getattr(self, "live_idle_max_interval", - _DEFAULT_LIVE_IDLE_MAX_SECONDS) - # The ceiling bounds the un-escalated interval too. The two settings are - # independent integers with no cross-validation, so base > ceiling is a - # reachable config -- and returning base unclamped there made the wait - # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), - # the opposite of what the setting named "maximum" promises. - if streak >= _IDLE_LONG_STREAK: - return min(int(base * _IDLE_LONG_FACTOR), ceiling) - if streak >= _IDLE_SHORT_STREAK: - return min(int(base * _IDLE_SHORT_FACTOR), ceiling) - return min(base, ceiling) - - def _note_live_fetch(self, found_live: bool) -> None: - """Record whether a look for live games found any.""" - if found_live: - if getattr(self, "_empty_live_streak", 0): - self.logger.info( - "Live games found after %d empty check(s); back to the " - "live update interval", self._empty_live_streak) - self._empty_live_streak = 0 - else: - self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 - def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 79171ddc..caff7acb 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.21.1", + "version": "2.23.0", "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,13 @@ "soccer_upcoming" ], "versions": [ + { + "version": "2.23.0", + "released": "2026-09-02", + "ledmatrix_min_version": "3.3.0", + "notes": "The sports.py logic every scoreboard shares moves to the core. Forty-five method bodies here were byte-identical to the same forty-five in every other scoreboard -- the selection and rotation engine, the font, colour and date subsystem, and the switch-mode upcoming card -- so they now come from src.common.sports_shared and this plugin inherits them. 1046 lines removed here. A fix to any of that now reaches every scoreboard at once instead of needing eight identical edits. Three deliberately stayed behind: _get_timezone, because it binds a per-plugin timezone module whose contents differ, and the two abstract stubs that define what makes this sport its own. Nothing drawn changes -- the bodies moved rather than being rewritten, and all 176 safety-harness renders across the eight plugins are byte-identical to before. The floor rises to 3.3.0, the release that first ships the shared module.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "2.21.1", "released": "2026-09-02", @@ -279,7 +286,7 @@ { "released": "2026-07-29", "version": "2.5.0", - "notes": "Corrected every team code in TEAMS.md against ESPN's live data \u2014 Manchester United is MAN (not MUN), Manchester City MNC (not MCI), Real Madrid RMA, and Ligue 1 had eight wrong codes. The plugin now also says why a league is empty: an unrecognised favorite team logs a warning naming the closest match, while a correct code in a league with no fixtures yet logs the date the season starts.", + "notes": "Corrected every team code in TEAMS.md against ESPN's live data — Manchester United is MAN (not MUN), Manchester City MNC (not MCI), Real Madrid RMA, and Ligue 1 had eight wrong codes. The plugin now also says why a league is empty: an unrecognised favorite team logs a warning naming the closest match, while a correct code in a league with no fixtures yet logs the date the season starts.", "ledmatrix_min": "2.0.0" }, { @@ -309,7 +316,7 @@ { "released": "2026-07-02", "version": "2.2.0", - "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores \u2014 spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league, including custom leagues.", + "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores — spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league, including custom leagues.", "ledmatrix_min": "2.0.0" }, { @@ -413,6 +420,6 @@ } ], "compatible_versions": [ - ">=3.2.0" + ">=3.3.0" ] } diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index 9f1f3799..b32a251e 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -41,6 +41,8 @@ if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) from src.logo_downloader import LogoDownloader, download_missing_logo +from src.common.sports_shared import ( + SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin) def _resolve_font_path(path: str) -> str: @@ -86,7 +88,6 @@ def _resolve_font_path(path: str) -> str: return path - # How far either side of now the schedule is fetched, now configurable. The # partial fetch that serves the display until the background fetch lands must # not be narrower than the fetch it substitutes for, or a game inside the real @@ -161,7 +162,7 @@ def _logo_needs_refresh(logo_file) -> bool: return False -class SportsCore(ABC): +class SportsCore(SportsCoreSharedMixin, ABC): def __init__( self, config: Dict[str, Any], @@ -500,24 +501,6 @@ def _get_logo_directory_fallbacks(self, configured_dir: Path) -> List[Path]: return unique_fallbacks - def _get_season_schedule_dates(self) -> tuple[str, str]: - return "", "" - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Placeholder draw method - subclasses should override.""" - # This base method will be simple, subclasses provide specifics - try: - img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - status = game.get("status_text", "N/A") - self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) - self.display_manager.image.paste(img, (0, 0)) - # Don't call update_display here, let subclasses handle it after drawing - except Exception as e: - self.logger.error( - f"Error in base _draw_scorebug_layout: {e}", exc_info=True - ) - def display(self, force_clear: bool = False) -> bool: """Render the current game. Returns False when nothing can be shown.""" if not self.is_enabled: # Check if module is enabled @@ -565,62 +548,6 @@ def display(self, force_clear: bool = False) -> bool: 'four_by_six': '4x6-font.ttf', } - @classmethod - def _crisp_size(cls, font_file, desired): - """Snap *desired* to the nearest size *font_file* renders crisply at. - - A face with no known grid is returned unchanged, so a user-supplied - font is never second-guessed. - """ - font_file = cls._FONT_NAME_ALIASES.get(font_file, font_file) - grid = cls._FONT_PIXEL_GRID.get(font_file) - if not grid or not desired or desired <= 0: - return desired - return max(grid, int(round(float(desired) / grid)) * grid) - - def _schema_font_size(self, element_key): - """The font_size this plugin's config_schema.json declares, or None.""" - if not element_key: - return None - cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None) - if cache is None: - cache = {} - try: - import json - schema_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'config_schema.json') - with open(schema_path) as fh: - schema = json.load(fh) - props = (schema.get('properties', {}) - .get('customization', {}) - .get('properties', {})) - for key, spec in props.items(): - size = spec.get('properties', {}).get('font_size', {}).get('default') - if size is not None: - cache[key] = int(size) - except Exception: - cache = {} - self.__class__._SCHEMA_FONT_SIZES = cache - return cache.get(element_key) - - def _resolve_font_size(self, element_config, element_key, default_size, font_name): - """Size to render at: the user's choice, or a grid-snapped default. - - A configured size counts as a real choice only when it differs from - the schema default. The web UI writes the whole schema default block - on every save, so "font_size == schema default" carries no intent and - would otherwise pin every install to an anti-aliased size forever. - """ - configured = (element_config or {}).get('font_size') - if configured is not None: - try: - configured = int(configured) - if configured != self._schema_font_size(element_key): - return configured - except (TypeError, ValueError): - pass - return self._crisp_size(font_name, default_size) - def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], default_size: int = 8, element_key=None, default_font: Optional[str] = None) -> ImageFont.FreeTypeFont: """ Load a custom font from an element configuration dictionary. @@ -714,124 +641,6 @@ def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", ) - def _card_option(self, key: str, default: Any = None) -> Any: - """Read one key from the scroll_card config block.""" - block = (self.config or {}).get("scroll_card") - if isinstance(block, dict) and block.get(key) is not None: - return block.get(key) - return default - - def _switch_upcoming_center(self) -> str: - """Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'.""" - mode = str(self._card_option("switch_upcoming_center", "date_time") - or "date_time").lower() - if mode == "inherit": - mode = str(self._card_option("upcoming_center", "vs") or "vs").lower() - return mode if mode in ("vs", "date_time", "none") else "date_time" - - def _vs_text(self) -> str: - """Separator drawn between the teams -- "VS", "@", "at", anything.""" - return str(self._card_option("vs_text", "VS")) - - def _switch_date_format(self) -> str: - """Date style for the full-screen scorebug. - - Its own key rather than the shared ``date_format`` because the two - displays disagree about the default: the scroll card renders "Sep 19" - while _extract_game_details_common emits "9/19", the "numeric" style, - and this scorebug has always drawn it. Reading the shared key here - would restyle every existing panel on update -- and "leave it alone - when unset" is not available, because the core merges schema defaults - into the config on every load, so the key is never actually unset. - "inherit" opts into the scroll and Vegas setting. - """ - fmt = str(self._card_option("switch_date_format", "numeric") or "numeric").lower() - if fmt == "inherit": - fmt = str(self._card_option("date_format", "abbrev") or "abbrev").lower() - return fmt - - def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str: - """Format an upcoming date per scroll_card.switch_date_format.""" - raw = str(date_text or "").strip() - if not raw: - return raw - fmt = self._switch_date_format() - if fmt == "numeric": - return raw - parts = raw.replace("-", "/").split("/") - if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): - return raw - month, day = int(parts[0]), int(parts[1]) - if not 1 <= month <= 12: - return raw - name = self._MONTH_ABBR[month - 1] - if fmt == "numeric_day_first": - return f"{day}/{month}" - if fmt == "day_first": - return f"{day} {name}" - if fmt == "weekday": - weekday = self._weekday_for(game) - return f"{weekday} {name} {day}" if weekday else f"{name} {day}" - return f"{name} {day}" - - def _weekday_for(self, game: Optional[Dict]) -> str: - """Weekday abbreviation from the game's start time, or ''.""" - if not game: - return "" - raw = game.get("start_time_utc") or game.get("start_time") - if not raw: - return "" - try: - start = raw if isinstance(raw, datetime) else datetime.fromisoformat( - str(raw).replace("Z", "+00:00")) - return self._WEEKDAY_ABBR[start.astimezone(self._get_timezone()).weekday()] - except (ValueError, TypeError, OverflowError): - return "" - - def _format_game_time(self, time_text: str) -> str: - """Return the time as-is (12h) or converted to 24h.""" - raw = str(time_text or "").strip() - if not raw or str(self._card_option("time_format", "12h")) != "24h": - return raw - cleaned = raw.upper().replace(" ", "") - meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" - if not meridiem: - return raw - try: - hh, _, mm = cleaned[:-2].partition(":") - hour, minute = int(hh), int(mm or 0) - except ValueError: - return raw - if not (0 <= hour <= 12 and 0 <= minute <= 59): - return raw - hour = hour % 12 + (12 if meridiem == "PM" else 0) - return f"{hour:02d}:{minute:02d}" - - def _scorebug_font(self, draw, text: str, width: int): - """The face this scorebug draws its date and time in. - - Always the "time" face, which is what this display has used for both - rows for as long as it has existed: changing switch_upcoming_center - moves the two lines around, it is not meant to restyle them, so the - type stays put while the placement changes. - - The single exception is text that cannot fit the panel at all. Only - the "weekday" date can do that -- "Fri Sep 19" measures 80px in an - 8px face, on a board 64px wide -- and the smaller "detail" face is a - better answer there than running off both edges. Every other date and - time this display can produce fits, so in practice the face never - changes; it is a floor, not a style rule. - """ - font = self.fonts["time"] - if not text: - return font - try: - if draw.textlength(text, font=font) + 2 <= width: - return font - except (TypeError, ValueError): - return font - return self.fonts.get("detail") or font - def _upcoming_date_and_time_text(self, game_date: str, game_time: str, game: Optional[Dict] = None) -> Tuple[str, str]: """The formatted (date, time) pair, blanked by show_date/show_time.""" @@ -841,106 +650,6 @@ def _upcoming_date_and_time_text(self, game_date: str, game_time: str, if self._card_option("show_time", True) else "") return date_text, time_text - def _draw_upcoming_center_switch(self, draw, game: Dict, center_y: int, - game_date: str, game_time: str, - display_width: Optional[int] = None, - display_height: Optional[int] = None, - date_element: str = 'date', - time_element: str = 'time', - second_row_y_offset: bool = True) -> bool: - """Draw the middle of the full-screen upcoming scorebug. - - Returns True when the header above it ("Next Game", or the league - name) should still be drawn. In "vs" and "none" the date and time move - out of the middle and into the top and bottom slots, mirroring the - scroll card -- and the top slot is where the header used to be, so the - caller drops it. - - ``date_element``/``time_element``/``second_row_y_offset`` exist only so - the layout-offset keys stay exactly what each plugin's schema - advertises; this sport's defaults are the common case. - """ - width = self.display_width if display_width is None else display_width - height = self.display_height if display_height is None else display_height - mode = self._switch_upcoming_center() - date_text, time_text = self._upcoming_date_and_time_text( - game_date, game_time, game) - swapped = bool(self._card_option("swap_date_time", False)) - - if mode == "date_time": - # Historically the date sat at center_y - 7 with the time 9px - # under it, and the time's row was derived from the date's, so a - # date y_offset moved the pair. Both still hold; the slots only - # trade places when swap_date_time is set, and hiding one line - # leaves the other where it was rather than re-centering the stack. - slots = [(time_element, time_text), (date_element, date_text)] if swapped \ - else [(date_element, date_text), (time_element, time_text)] - row_y = center_y - 7 - for index, (element, text) in enumerate(slots): - if index: - row_y += 9 - if second_row_y_offset: - row_y += self._get_layout_offset(element, 'y_offset') - else: - row_y += self._get_layout_offset(element, 'y_offset') - if not text: - continue - font = self._scorebug_font(draw, text, width) - text_width = draw.textlength(text, font=font) - text_x = ((width - text_width) // 2 - + self._get_layout_offset(element, 'x_offset')) - self._draw_text_with_outline( - draw, text, (text_x, row_y), font - ) - return True - - if mode == "vs": - vs_text = self._vs_text() - if vs_text: - vs_width = draw.textlength(vs_text, font=self.fonts["score"]) - vs_x = ((width - vs_width) // 2 - + self._get_layout_offset('score', 'x_offset')) - vs_y = (center_y - 3 - + self._get_layout_offset('score', 'y_offset')) - self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts["score"] - ) - - # "vs" and "none" both push the date and time out to the edges, time - # on top unless swap_date_time says otherwise -- the same order the - # scroll card uses. - if swapped: - top_element, top_text = date_element, date_text - bottom_element, bottom_text = time_element, time_text - else: - top_element, top_text = time_element, time_text - bottom_element, bottom_text = date_element, date_text - - if top_text: - top_font = self._scorebug_font(draw, top_text, width) - top_width = draw.textlength(top_text, font=top_font) - top_x = ((width - top_width) // 2 - + self._get_layout_offset(top_element, 'x_offset')) - top_y = 1 + self._get_layout_offset(top_element, 'y_offset') - self._draw_text_with_outline( - draw, top_text, (top_x, top_y), top_font - ) - if bottom_text: - bottom_font = self._scorebug_font(draw, bottom_text, width) - bottom_width = draw.textlength(bottom_text, font=bottom_font) - bottom_x = ((width - bottom_width) // 2 - + self._get_layout_offset(bottom_element, 'x_offset')) - # Measured, not a fixed offset: the detail font is 6px in most - # plugins and 10px in soccer and nrl, where a fixed -7 ran the - # date off the panel. - ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3] - bottom_y = (max(0, height - ink_bottom - 1) - + self._get_layout_offset(bottom_element, 'y_offset')) - self._draw_text_with_outline( - draw, bottom_text, (bottom_x, bottom_y), bottom_font - ) - return False - def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: """ Get layout offset for a specific element and axis. @@ -989,86 +698,6 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: "tie": (255, 200, 0), } - @staticmethod - def _coerce_rgb(value, fallback): - """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" - # Checked before unpacking: a 3-character string ("123") would otherwise - # iterate into three digits and yield a colour rather than the fallback. - if not isinstance(value, (list, tuple)) or len(value) != 3: - return fallback - try: - r, g, b = (max(0, min(255, int(channel))) for channel in value) - except (TypeError, ValueError): - return fallback - return (r, g, b) - - @staticmethod - def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool: - """Is the home/away side of this game a favorite team? - - Both the abbreviation and the ESPN id are checked, because a couple of - leagues (NRL) match favorites by id where abbreviations collide. - """ - for key in (f"{side}_abbr", f"{side}_id"): - value = game.get(key) - if value is not None and str(value).strip().upper() in favorites: - return True - return False - - def _favorite_result(self, game: Dict) -> Optional[str]: - """Say how the favorite team did in a finished game. - - Returns 'win', 'loss' or 'tie', or None when there is no single team - to root for: no favorites configured, neither side is a favorite, or - *both* are -- a favorite-vs-favorite game has no losing side worth - flagging in red. Also None when the scores are not usable numbers. - """ - favorites = getattr(self, "favorite_teams", None) or [] - favorites = {str(team).strip().upper() for team in favorites if str(team).strip()} - if not favorites: - return None - - home_fav = self._side_is_favorite(game, "home", favorites) - away_fav = self._side_is_favorite(game, "away", favorites) - if home_fav == away_fav: - return None - - try: - # int(float(...)) to match GameRenderer._side_score exactly -- the - # two paths must agree on what counts as a usable score. - home_score = int(float(str(game.get("home_score", "")).strip())) - away_score = int(float(str(game.get("away_score", "")).strip())) - except (TypeError, ValueError): - return None - - if home_score == away_score: - return "tie" - favorite_score, other_score = ( - (home_score, away_score) if home_fav else (away_score, home_score) - ) - return "win" if favorite_score > other_score else "loss" - - def _recent_score_color(self, game: Dict, default): - """Fill color for a finished game's score, per favorite_result_colors.""" - try: - settings = (self.config.get("customization") or {}).get( - "favorite_result_colors" - ) or {} - if not settings.get("enabled", False): - return default - result = self._favorite_result(game) - if result is None: - return default - return self._coerce_rgb( - settings.get(f"{result}_color"), - self.FAVORITE_RESULT_COLOR_DEFAULTS[result], - ) - except Exception: - self.logger.debug( - "Could not resolve favorite result color", exc_info=True - ) - return default - #: How far each logo is shifted outward, off the panel edge, by the #: scorebug layouts (they paste at -2 and width - logo_width + 2). Kept #: here because the logo sizing has to know it. @@ -1216,137 +845,6 @@ def _fit_score_font(self, fonts): #: -- so on a taller panel they grew and the score did not. _FONT_DESIGN_HEIGHT: ClassVar[int] = 32 - def _score_font_size(self) -> int: - """Pixel size the score is currently drawn at.""" - return getattr(self.fonts.get("score"), "size", 8) or 8 - - def _time_font_size(self) -> int: - """Pixel size the clock/date face is currently drawn at.""" - return getattr(self.fonts.get("time"), "size", 8) or 8 - - def _user_chose_size(self, element_key: str) -> bool: - """True when customization..font_size is a real choice. - - The web UI's save flow writes the whole schema default block into - config.json on every save, whether or not the user touched that - section, so a size merely being PRESENT carries no intent. Only one - that differs from the schema default does. - """ - element = (self.config.get('customization', {}) or {}).get(element_key) or {} - configured = element.get('font_size') - if configured is None: - return False - try: - return int(configured) != self._schema_font_size(element_key) - except (TypeError, ValueError): - return False - - def _grid_scaled_size(self, font): - """(path, grid, size) for *font* regrown to this panel's height. - - None when the panel is at or below the design height (nothing to do), - or when the face has no known pixel grid -- a user-supplied font is - never second-guessed, because we do not know what it renders crisply - at. - """ - path = getattr(font, 'path', None) - base = getattr(font, 'size', None) - if not base or not isinstance(path, str): - return None - face = os.path.basename(path) - grid = self._FONT_PIXEL_GRID.get(self._FONT_NAME_ALIASES.get(face, face)) - if not grid: - return None - scale = float(self.display_height) / (self._FONT_DESIGN_HEIGHT or 32) - if scale <= 1.0: - return None - return path, grid, max(int(base), int(self._crisp_size(face, base * scale))) - - def _scale_headline_fonts(self, fonts): - """Grow the score with the panel, and hold the clock/date below it. - - The score is the one number the card exists to show, and it was the - only element not sized from the panel. Worse, it was not even bigger - than its neighbours: PressStart2P renders crisply on an 8px grid, so - the 10px default snapped to 8 -- the same 8 the period/clock above it - and the game date below it are drawn at. Three lines of identical - type, none of them the headline, which is what makes the score read as - lower priority than the time and the date rather than the point of the - card. - - So the score is sized from display_height and snapped to its face's - pixel grid (off the grid FreeType anti-aliases the strokes, and on an - LED matrix a part-lit pixel is a dim lamp rather than a soft edge), - then stepped back down that grid until it fits its share of the width. - The clock/date face is regrown the same way but held at least one grid - step below the score, so the ranking between them is visible rather - than implied. - - A 32-tall panel scales by exactly 1.0 and is left byte-identical; a - size the user set explicitly is never overridden. - """ - self._score_grew = False - if not self._DRAWS_SCORE: - # No score on this screen, so none of the sizing below is for it. - return fonts - try: - scaled = None if self._user_chose_size('score_text') else \ - self._grid_scaled_size(fonts.get('score')) - if scaled is not None: - path, grid, size = scaled - base = getattr(fonts['score'], 'size', size) or size - size = min(size, base * self._SCORE_MAX_GROWTH) - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - # Measured from a fixed five-character score rather than the - # live one, so the card does not resize when a side passes 9. - while size > grid: - if probe.textlength( - self._SCORE_PROBE_TEXT, - font=ImageFont.truetype(path, size)) <= budget: - break - size -= grid - if size != getattr(fonts['score'], 'size', size): - fonts['score'] = ImageFont.truetype(path, size) - self._score_grew = True - - if not self._score_grew and not self._user_chose_size('score_text') \ - and self.display_height > self._FONT_DESIGN_HEIGHT: - # PressStart2P could not grow inside the budget -- its next crisp - # size is simply too wide for this panel. A narrower face still - # can: 4x6-font at 14px is nearly as tall as PressStart2P at 16 - # and about half as wide. This matters beyond the score itself, - # because a card whose score never grows never reserves the - # centre either, so its logos stay at the uncapped 1.5x and are - # drawn straight over the score -- which is what a three-digit - # basketball score does on a 128x64 board. - probe = ImageDraw.Draw(Image.new('RGB', (4, 4))) - budget = self.display_width * self._SCORE_GROWTH_BUDGET - current = getattr(fonts.get('score'), 'size', 0) or 0 - for _name, _size in self._NARROW_SCORE_RUNGS: - if _size <= current: - continue - _path = _resolve_font_path(f"assets/fonts/{_name}") - _candidate = ImageFont.truetype(_path, _size) - if probe.textlength(self._SCORE_PROBE_TEXT, - font=_candidate) <= budget: - fonts['score'] = _candidate - self._score_grew = True - break - - scaled = None if self._user_chose_size('period_text') else \ - self._grid_scaled_size(fonts.get('time')) - if scaled is not None: - path, grid, size = scaled - ceiling = getattr(fonts.get('score'), 'size', 0) or 0 - if ceiling and size >= ceiling: - size = max(grid, ceiling - grid) - if size != getattr(fonts['time'], 'size', size): - fonts['time'] = ImageFont.truetype(path, size) - except Exception: - self.logger.debug("Headline font scaling skipped", exc_info=True) - return fonts - def _load_fonts(self): """Load fonts used by the scoreboard from config or use defaults.""" fonts = {} @@ -1537,105 +1035,6 @@ def _draw_dynamic_odds( "rank": "rank_text", } - def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): - """Per-element text colour from customization..text_color.""" - try: - cfg = (self.config or {}).get("customization", {}).get(element, {}) - value = cfg.get("text_color") - if isinstance(value, (list, tuple)) and len(value) == 3: - return tuple(max(0, min(255, int(c))) for c in value) - if isinstance(value, str) and value.startswith("#") and len(value) == 7: - return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) - except (TypeError, ValueError): - pass - return default - - def _unshare_element_fonts(self, fonts): - """Give each colourable element its own face object. - - The colour a draw gets is resolved from the face it was handed, and - several of these loaders legitimately hand one object to more than one - element -- a size resolver that lands two elements on the same face, a - fallback that fills every key from one default, football's narrowing - step that deliberately shrinks the clock along with the score. Sharing - the object makes the element ambiguous and the colour unresolvable. - - Re-instantiating from the same path and size gives a distinct object - with identical metrics, so nothing about the rendering changes; only - the ability to tell two elements apart does. Faces that cannot be - rebuilt (a BDF loaded through freetype.Face, anything without a usable - path) are left shared, and their draws stay white as before. - """ - try: - from PIL import ImageFont as _IF - except ImportError: # pragma: no cover - return fonts - seen = {} - for key in self._ELEMENT_FOR_FONT: - font = fonts.get(key) - if font is None: - continue - if id(font) not in seen: - seen[id(font)] = key - continue - path, size = getattr(font, "path", None), getattr(font, "size", None) - if not path or not size: - continue - try: - fonts[key] = _IF.truetype(path, size) - except (OSError, ValueError, TypeError): - self.logger.debug( - "Could not un-share the %s face; it keeps the default colour", key) - return fonts - - def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): - """Colour for whichever element owns this face. - - Matched on identity, and deliberately gives up when one object is - shared: the last-resort font path can hand the same face to several - keys, and there is no right answer for which element's colour that is. - White is what those draws used before, so ambiguity costs nothing. - """ - try: - fonts = getattr(self, "fonts", None) or {} - matches = [element for key, element in self._ELEMENT_FOR_FONT.items() - if fonts.get(key) is font] - if len(matches) == 1: - return self._element_color(matches[0], default) - except (AttributeError, TypeError): - pass - return default - - def _draw_text_with_outline( - self, draw, text, position, font, fill=None, outline_color=(0, 0, 0) - ): - """Draw text with a black outline for better readability.""" - # Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get - # anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying - # glyphs. 1-bit mode keeps strokes crisp. - # Defaults to the configured colour for whichever element owns - # this face rather than to white, so customization..text_color - # reaches every draw. The schema has offered those pickers all along - # and they only ever changed the font. An explicit fill still wins: - # the odds colours and the favourite-result score tint mean something - # the palette does not. - if fill is None: - fill = self._font_color(font) - draw.fontmode = "1" - x, y = position - for dx, dy in [ - (-1, -1), - (-1, 0), - (-1, 1), - (0, -1), - (0, 1), - (1, -1), - (1, 0), - (1, 1), - ]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - def _load_and_resize_logo( self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]: @@ -1795,14 +1194,6 @@ def _get_timezone(self): log=self.logger, ) - def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: - """Check if we should log a warning based on cooldown period.""" - current_time = time.time() - if current_time - self._last_warning_time > cooldown: - self._last_warning_time = current_time - return True - return False - # Which ranking block the badge reads. ESPN answers /rankings with more # than one block for several leagues, and the FIRST is not always a poll: # men's and women's college hockey front "NCAA Men's/Women's Hockey @@ -2180,51 +1571,6 @@ def _fetch_todays_games(self) -> Optional[Dict]: ) return None - def _get_weeks_data(self) -> Optional[Dict]: - """ - Get partial data for immediate display while background fetch is in progress. - This fetches current/recent games only for quick response. - """ - try: - # Fetch current week and next few days for immediate display - now = datetime.now(pytz.utc) - immediate_events = [] - - # Same horizon as the full fetch this stands in for - # (_fetch_soccer_api_data, -14d..+14d). It used to end a week - # earlier, which is invisible in a league that plays daily and - # severe in one that plays weekly: on 2026-08-14 the Premier - # League's opening matchweek was 21-24 August, so a +7d horizon - # returned exactly one fixture (COV @ ARS on the 21st) and hid the - # other nine, including Man Utd on the 22nd. Reported as a - # favourite team never appearing while other clubs did. - start_date = now - timedelta(days=self.schedule_lookback_days) - end_date = now + timedelta(days=self.schedule_lookahead_days) - date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" - url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" - response = self.session.get( - url, - params={"dates": date_str, "limit": 1000}, - headers=self.headers, - timeout=10, - ) - response.raise_for_status() - data = response.json() - immediate_events = data.get("events", []) - - if immediate_events: - self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") - return {"events": immediate_events} - - except requests.exceptions.RequestException as e: - self.logger.warning( - f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}" - ) - return None - - def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): - pass - def _get_team_record_text(self, abbr: str, record: str) -> str: """Return the corner text for a team: ranking (if enabled/available) or record. @@ -2239,21 +1585,6 @@ def _get_team_record_text(self, abbr: str, record: str) -> str: return record return "" - def cleanup(self): - """Clean up resources when plugin is unloaded.""" - # Close HTTP session - if hasattr(self, 'session') and self.session: - try: - self.session.close() - except Exception as e: - self.logger.warning(f"Error closing session: {e}") - - # Clear caches - if hasattr(self, '_logo_cache'): - self._logo_cache.clear() - - self.logger.info(f"{self.__class__.__name__} cleanup completed") - def _is_favorite_game(self, game: Dict) -> bool: return bool(self.favorite_teams) and ( game.get("home_abbr") in self.favorite_teams @@ -2360,64 +1691,6 @@ def _load_division_team_ids(self) -> Dict[str, set]: self._division_team_ids[name] = ids return self._division_team_ids - def _game_divisions(self, game: Dict) -> Optional[set]: - """Divisions of BOTH sides, or None when they cannot be told. - - Both sides are collected, but the caller only needs ONE of them to sit - in a checked division. Requiring every participant read as "FBS games - only" and removed a ranked side hosting an FCS school -- which is still - a game involving a team the viewer checked the box for, and on a real - Week 2 slate it silently dropped five of the twenty ranked matchups. - What the checkbox is for is keeping FCS-versus-FCS out of a board - configured for FBS, and that still holds: a game with no checked - division on either side is dropped. - """ - divisions = self._load_division_team_ids() - if not any(divisions.values()): - return None - try: - ids = [int(game.get("home_id")), int(game.get("away_id"))] - except (TypeError, ValueError): - return None - present = set() - for team_id in ids: - for name in ("fbs", "fcs"): - if team_id in divisions.get(name, set()): - present.add(name) - break - else: - present.add("other") - return present - - def _league_has_rankings(self) -> bool: - """Only college leagues publish a poll; everyone else 404s. - - This gate matters more than it looks. _fetch_team_rankings only - short-circuits when the cache is non-empty, so a failed fetch leaves it - empty and the next update tries again -- at a 30s interval that is - ~2,900 pointless requests a day, per league, all of them 404s. - """ - league = (self.league or "").lower() - return "college" in league or "ncaa" in league - - @staticmethod - def _normalise_divisions(raw) -> List[str]: - """Division names from config, in the shape the filter expects. - - A hand-edited config can hold "fbs" where the schema says ["fbs"], and - list("fbs") is ['f', 'b', 's'] -- three names that match no division, so - every non-favourite game is rejected by a setting the user believes says - the opposite. An empty list is left empty: that means "no division - filter" and is a legitimate choice, not a mistake to correct. - """ - if isinstance(raw, str): - raw = [raw] - try: - items = list(raw or []) - except TypeError: - return [] - return [str(d).strip().lower() for d in items if str(d).strip()] - def _setting_int(self, key: str, default: int, low: int, high: int) -> int: """A count from config, clamped to the range its schema declares. @@ -2455,64 +1728,6 @@ def _best_rank(self, game: Dict) -> int: rankings.get(game.get("away_abbr"), 0)) if r] return min(ranked) if ranked else 99 - def _round_robin_favorites(self, games: List[Dict], limit: int) -> List[Dict]: - """Each favourite team's next game before any team's second one. - - Taking the soonest N favourite games spends the slots on whoever plays - most often. Walked across a real season with two favourites and a limit - of 2, nine days of it showed Auburn twice and Georgia not at all -- - Auburn played either side of a Georgia bye, so both slots went to - Auburn. The other-games pool already refuses to do this; favourites - were still doing it. - - Depth is kept where there is room: one favourite with three slots still - gets its next three games, because the round-robin only comes back for - a team's second game once every team has had a first. - - A game between two favourites is picked once and counts for both. - """ - if limit <= 0 or not games: - return [] - wanted = [t for t in (self.favorite_teams or []) if t] - if len(wanted) < 2: - return games[:limit] # nothing to share the slots between - - # Which side of a game belongs to which favourite is a per-lineage - # question: NRL matches on ESPN team IDs because its abbreviations are - # not unique ("NEW" is both Newcastle and New Zealand), while the rest - # match on abbreviation. Ask for the lineage's own matcher rather than - # assuming, or this silently groups nothing and every slot goes empty. - team_in = getattr(self, "_team_in", None) - if callable(team_in): - def belongs(game, team): - return bool(team_in(game.get("home_id"), [team]) - or team_in(game.get("away_id"), [team])) - else: - def belongs(game, team): - return team in (game.get("home_abbr"), game.get("away_abbr")) - - queues = {team: [] for team in wanted} - for game in games: # already in kickoff order - for team in wanted: - if belongs(game, team): - queues[team].append(game) - - picked, taken = [], set() - while len(picked) < limit: - progressed = False - for team in wanted: - queue = queues[team] - while queue and queue[0].get("id") in taken: - queue.pop(0) - if queue and len(picked) < limit: - game = queue.pop(0) - taken.add(game.get("id")) - picked.append(game) - progressed = True - if not progressed: - break # every queue is empty - return picked - def _by_importance(self, games: List[Dict], newest_first: bool = False) -> List[Dict]: """Non-favourite games, best matchup first. @@ -2577,35 +1792,6 @@ def key(game): #: migrates to "ranked" -- see _normalise_quality. _QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"}) - def _normalise_quality(self, raw) -> str: - """other_games_min_quality, as one of the values the code implements. - - An unusable value used to fall through every branch of - _passes_other_filters and silently mean "any" -- a quality bar the - board believes it has and does not. - """ - value = str(raw or "").strip().lower() - if value in self._QUALITY_CHOICES: - return value - if value == "broadcast": - # Retired in football-scoreboard 3.0.0 and now here. Measured - # against a real Week 1 and Week 2 college slate it passed 174 of - # 175 games: ESPN publishes a broadcaster for nearly everything - # now, ESPN+ included, so the tier read as a quality bar and - # behaved as "any". Boards holding it get the bar they thought - # they were getting. - self.logger.warning( - "%s: other_games_min_quality 'broadcast' has been retired -- " - "it let through nearly every game -- using 'ranked'. Change " - "the setting to clear this.", getattr(self, "sport_key", "?"), - ) - return "ranked" - self.logger.warning( - "%s: ignoring unusable other_games_min_quality=%r, using 'ranked'", - getattr(self, "sport_key", "?"), raw, - ) - return "ranked" - def _passes_other_filters(self, game: Dict) -> bool: """Is this non-favourite game worth one of the remaining slots? @@ -2645,44 +1831,6 @@ def _filtered_or_all(self, games: List[Dict]) -> List[Dict]: return kept or games - def _check_ranking_coverage(self, games: List[Dict]) -> None: - """Say so when a loaded poll matches nothing on the schedule. - - The table is keyed by the abbreviation the RANKINGS endpoint returns and - matched against the one the SCOREBOARD endpoint returns. Nothing - guarantees the two agree, and if they ever stop agreeing the filter - quietly removes every non-favourite game -- no exception, no log line, - just a shorter board. That is the same shape as the bug where rankings - were never loading at all, which survived until someone went looking. - - Throttled to once an hour: selection runs on every update. - """ - if self.other_games_min_quality != "ranked": - return - rankings = getattr(self, "_team_rankings_cache", None) or {} - if not rankings or not games: - return - if any(self._is_ranked_game(g) for g in games): - return - now = time.monotonic() - # Zero means never logged, not "logged at the epoch". monotonic() counts - # from an arbitrary origin -- on a freshly booted board it is a few - # hundred seconds -- so comparing against 0 swallowed the first warning - # for the first hour of uptime, which is exactly when a misconfigured - # board is being watched. CI caught this; a machine with days of uptime - # cannot. - if (self._ranking_coverage_logged_at - and now - self._ranking_coverage_logged_at < self._RANKING_COVERAGE_SECONDS): - return - self._ranking_coverage_logged_at = now - self.logger.warning( - "%s: %d ranked teams loaded, but none of the %d other games match " - "one -- the quality filter is removing every non-favourite game. " - "Ranked abbreviations look like: %s", - self.league, len(rankings), len(games), - ", ".join(sorted(rankings)[:8]), - ) - def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: """A rotating slice of the non-favourite games. @@ -2721,98 +1869,6 @@ def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]: window += others[:limit - len(window)] return window - def _favorites_first( - self, - processed_games: List[Dict], - favorite_limit: int, - other_limit: int, - newest_first: bool = False, - ) -> List[Dict]: - """Favourite games first, then a bounded number of everything else. - - This is the middle setting the plugin was missing. `show_favorite_teams_only` - used to be the whole story: on, and you saw nothing but your teams; off, - and your teams were ignored entirely -- the selection just took the next - N games league-wide, so a UGA fan with 946 upcoming college games in the - window saw UGA about as often as chance allowed. - - Both counts are TOTALS here, not per-team. In favourites-only mode - `upcoming_games_to_show` is a per-team budget, which is reasonable when - the list is your own teams; applied to a dynamic group it is not. With - AP_TOP_10 resolving to a dozen teams, three games each is 28 distinct - cards before a single non-favourite is added. A total keeps the rotation - the length the user asked for. - """ - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key, reverse=True) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - ordered = sorted(processed_games, key=key) - - favorites, others, unfiltered = [], [], [] - for game in ordered: - if self._is_favorite_game(game): - favorites.append(game) # never filtered: your team is your team - continue - unfiltered.append(game) - if self._passes_other_filters(game): - others.append(game) - self._check_ranking_coverage(unfiltered) - - self._selection_pools = { - "favorites": favorites, - "others": self._by_importance(others, newest_first), - "unfiltered": self._by_importance(unfiltered, newest_first), - "favorite_limit": favorite_limit, - "other_limit": other_limit, - "newest_first": newest_first, - } - return self._compose_selection() - - def _compose_selection(self) -> List[Dict]: - """Favourites plus the current slice of others, in schedule order. - - Split out of _favorites_first so the slice can be re-cut between - fetches. The pools are settled -- which games exist, and which of them - are worth a slot -- while WHICH of the others is on screen is a display - decision, and gating it on the fetch made the rotation interval a lie: - update() returns early until upcoming_update_interval has passed, so a - four-minute rotation actually stepped fifteen windows once an hour. - Same lesson as _advance_live_game_if_due further down this file. - """ - pools = self._selection_pools - favorites, others = pools["favorites"], pools["others"] - favorite_limit, other_limit = pools["favorite_limit"], pools["other_limit"] - newest_first = pools["newest_first"] - if newest_first: - def key(g): - return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc) - else: - def key(g): - return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc) - - selected = self._round_robin_favorites(favorites, max(0, favorite_limit)) - selected.extend(self._other_games_window(others, max(0, other_limit))) - if not selected and other_limit > 0: - # Nothing survived at all: your teams are not playing inside the - # schedule window AND the filters removed every other game. Each - # check fails open on missing data, but a filter working exactly as - # asked can still match nothing on a given day, and with no - # favourite game left there is nothing to carry the mode -- an empty - # list is a blank panel, not a short one. Same whole-list fallback - # `_filtered_or_all` makes for a board with no favourites at all. - # `other_limit` of 0 is an explicit "favourites only", so that one - # is left to go quiet as asked. - selected = self._other_games_window(pools["unfiltered"], max(0, other_limit)) - # Re-sort so the card order still reads as a schedule. Selection decides - # WHICH games; it should not reorder them into favourites-then-others, - # which would show next week's UGA game before tonight's. - selected.sort(key=key, reverse=newest_first) - return selected - def _rotate_other_games_on_display(self) -> bool: """Swap in a freshly cut slice when the rotation interval has passed. @@ -3441,39 +2497,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsRecent(SportsCore): - - def __init__( - self, - config: Dict[str, Any], - display_manager, - cache_manager, - logger: logging.Logger, - sport_key: str, - ): - super().__init__(config, display_manager, cache_manager, logger, sport_key) - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mode_config.get( - "recent_update_interval", 3600 - ) # Check for recent games every hour - self.last_game_switch = 0 - self.game_display_duration = self.mode_config.get("recent_game_duration", 15) - self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 - - def _get_zero_clock_duration(self, game_id: str) -> float: - """Track how long a game has been at 0:00 clock.""" - current_time = time.time() - if game_id not in self._zero_clock_timestamps: - self._zero_clock_timestamps[game_id] = current_time - return 0.0 - return current_time - self._zero_clock_timestamps[game_id] - - def _clear_zero_clock_tracking(self, game_id: str) -> None: - """Clear tracking when game clock moves away from 0:00 or game ends.""" - if game_id in self._zero_clock_timestamps: - del self._zero_clock_timestamps[game_id] +class SportsRecent(SportsRecentSharedMixin, SportsCore): def _select_recent_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] @@ -4035,7 +3059,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -4565,76 +3589,6 @@ def _is_game_really_over(self, game: Dict) -> bool: ) return False - def _detect_stale_games(self, games: List[Dict]) -> None: - """Remove games that appear stale or haven't updated.""" - current_time = time.time() - - for game in games[:]: # Copy list to iterate safely - game_id = game.get("id") - if not game_id: - continue - - # Check if game data is stale - timestamps = self.game_update_timestamps.get(game_id, {}) - last_seen = timestamps.get("last_seen", 0) - - if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: - self.logger.warning( - f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(last seen {int(current_time - last_seen)}s ago)" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - continue - - # Also check if game appears to be over - if self._is_game_really_over(game): - self.logger.debug( - f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " - f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" - ) - games.remove(game) - if game_id in self.game_update_timestamps: - del self.game_update_timestamps[game_id] - - def _idle_live_interval(self) -> int: - """How long to wait before looking for live games again, when there are none. - - Escalates the longer nothing turns up, and any live game resets it, so - an in-season gap between games costs at most one escalated wait while - an out-of-season league stops polling on a live cadence entirely. - - Capped rather than unbounded: the cost of backing off is how late the - first game after a quiet spell is noticed, and past the cap the saving - stops being worth that. - """ - streak = getattr(self, "_empty_live_streak", 0) - base = self.no_data_interval - ceiling = getattr(self, "live_idle_max_interval", - _DEFAULT_LIVE_IDLE_MAX_SECONDS) - # The ceiling bounds the un-escalated interval too. The two settings are - # independent integers with no cross-validation, so base > ceiling is a - # reachable config -- and returning base unclamped there made the wait - # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), - # the opposite of what the setting named "maximum" promises. - if streak >= _IDLE_LONG_STREAK: - return min(int(base * _IDLE_LONG_FACTOR), ceiling) - if streak >= _IDLE_SHORT_STREAK: - return min(int(base * _IDLE_SHORT_FACTOR), ceiling) - return min(base, ceiling) - - def _note_live_fetch(self, found_live: bool) -> None: - """Record whether a look for live games found any.""" - if found_live: - if getattr(self, "_empty_live_streak", 0): - self.logger.info( - "Live games found after %d empty check(s); back to the " - "live update interval", self._empty_live_streak) - self._empty_live_streak = 0 - else: - self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 - def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: diff --git a/plugins/soccer-scoreboard/test/test_empty_mode_no_blank.py b/plugins/soccer-scoreboard/test/test_empty_mode_no_blank.py index abf3e6d7..a3be399e 100644 --- a/plugins/soccer-scoreboard/test/test_empty_mode_no_blank.py +++ b/plugins/soccer-scoreboard/test/test_empty_mode_no_blank.py @@ -26,6 +26,7 @@ from __future__ import annotations import logging +import os import sys import types from pathlib import Path @@ -53,6 +54,21 @@ def _install_host_stubs() -> None: sys.modules["src.logo_downloader"].LogoDownloader = object sys.modules["src.logo_downloader"].download_missing_logo = lambda *a, **k: None + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + _install_host_stubs() logging.basicConfig(level=logging.CRITICAL) diff --git a/plugins/soccer-scoreboard/test_custom_league_config.py b/plugins/soccer-scoreboard/test_custom_league_config.py index bc6bb4c1..eca4d2ad 100644 --- a/plugins/soccer-scoreboard/test_custom_league_config.py +++ b/plugins/soccer-scoreboard/test_custom_league_config.py @@ -21,6 +21,7 @@ import importlib.util import json import re +import os import sys import types from pathlib import Path @@ -46,6 +47,23 @@ def mod(name, **attrs): mod("src.plugin_system.base_plugin", BasePlugin=None, VegasDisplayMode=object) mod("src.background_data_service", get_background_service=lambda *a, **k: None) + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + _stub_core_src() @@ -308,4 +326,4 @@ def make_plugin(custom_leagues): if failed: for case in failed: print(f" FAILED: {case}") - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/plugins/soccer-scoreboard/test_goal_celebration.py b/plugins/soccer-scoreboard/test_goal_celebration.py index 62d4a48e..295eb33f 100644 --- a/plugins/soccer-scoreboard/test_goal_celebration.py +++ b/plugins/soccer-scoreboard/test_goal_celebration.py @@ -56,6 +56,23 @@ def _stub_download_missing_logo(*a, **k): sys.modules["src"] = src_pkg sys.modules["src.logo_downloader"] = logo_mod + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + logging.basicConfig(level=logging.ERROR) diff --git a/plugins/soccer-scoreboard/test_league_registry_atomic_swap.py b/plugins/soccer-scoreboard/test_league_registry_atomic_swap.py index 5e15c3ed..b218595d 100644 --- a/plugins/soccer-scoreboard/test_league_registry_atomic_swap.py +++ b/plugins/soccer-scoreboard/test_league_registry_atomic_swap.py @@ -20,6 +20,7 @@ Run: /bin/python plugins/soccer-scoreboard/test_league_registry_atomic_swap.py """ +import os import sys import types from pathlib import Path @@ -44,6 +45,23 @@ def mod(name, **attrs): mod("src.plugin_system.base_plugin", BasePlugin=None, VegasDisplayMode=object) mod("src.background_data_service", get_background_service=lambda *a, **k: None) + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + _stub_core_src() diff --git a/plugins/soccer-scoreboard/test_live_mode_targeting.py b/plugins/soccer-scoreboard/test_live_mode_targeting.py index 7d608db3..dfaa2dc2 100644 --- a/plugins/soccer-scoreboard/test_live_mode_targeting.py +++ b/plugins/soccer-scoreboard/test_live_mode_targeting.py @@ -14,6 +14,7 @@ Run: /bin/python plugins/soccer-scoreboard/test_live_mode_targeting.py """ +import os import sys import threading import types @@ -46,6 +47,23 @@ def mod(name, **attrs): mod("src.plugin_system.base_plugin", BasePlugin=None, VegasDisplayMode=object) mod("src.background_data_service", get_background_service=lambda *a, **k: None) + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + _stub_core_src() diff --git a/plugins/soccer-scoreboard/test_non_favorite_live_duration.py b/plugins/soccer-scoreboard/test_non_favorite_live_duration.py index 580241bc..9c2b321c 100644 --- a/plugins/soccer-scoreboard/test_non_favorite_live_duration.py +++ b/plugins/soccer-scoreboard/test_non_favorite_live_duration.py @@ -9,6 +9,7 @@ Run: /bin/python plugins/soccer-scoreboard/test_non_favorite_live_duration.py """ +import os import sys import types from pathlib import Path @@ -33,6 +34,23 @@ def mod(name, **attrs): mod("src.plugin_system.base_plugin", BasePlugin=None, VegasDisplayMode=object) mod("src.background_data_service", get_background_service=lambda *a, **k: None) + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + _stub_core_src() diff --git a/plugins/soccer-scoreboard/test_schedule_horizon.py b/plugins/soccer-scoreboard/test_schedule_horizon.py index 364cc2e9..eacbaf5c 100644 --- a/plugins/soccer-scoreboard/test_schedule_horizon.py +++ b/plugins/soccer-scoreboard/test_schedule_horizon.py @@ -22,6 +22,7 @@ """ import ast +import os import sys from datetime import datetime, timedelta from pathlib import Path @@ -36,6 +37,18 @@ import sports # noqa: E402 + +def _shared_mixin_path(): + """Where the core keeps the bodies shared by every scoreboard, if present.""" + from pathlib import Path as _P + for cand in [os.environ.get("LEDMATRIX_CORE", "")] + sys.path: + if not cand: + continue + probe = _P(cand) / "src" / "common" / "sports_shared.py" + if probe.is_file(): + return probe + return None + failures = [] @@ -112,10 +125,26 @@ def main(): and sports._clamp_window(-5, 7) == sports._MIN_WINDOW_DAYS) print("\n_get_weeks_data uses the setting rather than its own numbers") - src = (plugin_dir / "sports.py").read_text(encoding="utf-8") - fn = next((n for n in ast.walk(ast.parse(src)) if isinstance(n, ast.FunctionDef) - and n.name == "_get_weeks_data"), None) + # _get_weeks_data is byte-identical in all eight scoreboards, so it now + # lives in the core's SportsCoreSharedMixin. Look there when it is no + # longer in sports.py -- the assertions below are about the body, which + # moved verbatim, so they hold wherever it is defined. + def _find_get_weeks_data(): + for candidate in (plugin_dir / "sports.py", _shared_mixin_path()): + if candidate and candidate.is_file(): + tree = ast.parse(candidate.read_text(encoding="utf-8")) + fn = next((n for n in ast.walk(tree) + if isinstance(n, ast.FunctionDef) + and n.name == "_get_weeks_data"), None) + if fn is not None: + return fn + return None + + fn = _find_get_weeks_data() check("_get_weeks_data exists", fn is not None) + if fn is None: + print(" [skip] _get_weeks_data not found in sports.py or the core mixin") + raise SystemExit(2) literal_deltas = [n for n in ast.walk(fn) if isinstance(n, ast.Call) and getattr(n.func, "id", "") == "timedelta" diff --git a/plugins/soccer-scoreboard/test_world_cup_flags.py b/plugins/soccer-scoreboard/test_world_cup_flags.py index 2a0ba190..98288f92 100644 --- a/plugins/soccer-scoreboard/test_world_cup_flags.py +++ b/plugins/soccer-scoreboard/test_world_cup_flags.py @@ -22,6 +22,7 @@ from __future__ import annotations import logging +import os import sys import tempfile import types @@ -69,6 +70,23 @@ def get_logo_filename_variations(abbr): logo_mod.download_missing_logo = lambda *a, **k: False sys.modules["src.logo_downloader"] = logo_mod + # The stubs above are plain ModuleTypes, so `from src.common.X import Y` + # fails with "'src.common' is not a package" even when a real core is on + # the path. Giving them a __path__ lets genuine submodules -- sports_shared, + # sports_card -- resolve from the core while the stubbed ones stay stubbed. + # Stubbing those too would make this test pass against dummies instead of + # the code under test. + _core = os.environ.get("LEDMATRIX_CORE") or next( + (p for p in sys.path + if p and os.path.isdir(os.path.join(p, "src", "common"))), None) + if _core: + if "src" in sys.modules and not hasattr(sys.modules["src"], "__path__"): + sys.modules["src"].__path__ = [os.path.join(_core, "src")] + if ("src.common" in sys.modules + and not hasattr(sys.modules["src.common"], "__path__")): + sys.modules["src.common"].__path__ = [ + os.path.join(_core, "src", "common")] + def _make_manager_stub(): """Build a BaseSoccerManager without running its heavy __init__."""