diff --git a/plugins.json b/plugins.json index c6c85160..bee8eef7 100644 --- a/plugins.json +++ b/plugins.json @@ -76,7 +76,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.39.1" + "latest_version": "1.40.0" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.28.1" + "latest_version": "1.29.0" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "3.3.1" + "latest_version": "3.4.0" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.24.1", + "latest_version": "1.25.0", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.23.1", + "latest_version": "1.24.0", "icon": "fas fa-baseball-ball" }, { @@ -760,7 +760,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "2.23.1" + "latest_version": "2.24.0" }, { "id": "static-image", @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.21.1", + "latest_version": "1.22.0", "last_updated": "2026-09-02" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.20.1" + "latest_version": "1.21.0" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index a5d417e6..68f04d74 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.21.1", + "version": "1.22.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.22.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.21.1", "released": "2026-09-02", diff --git a/plugins/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py index 1c38814d..79786021 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 = {} @@ -1552,105 +1050,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]: @@ -1801,14 +1200,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 @@ -2186,43 +1577,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. @@ -2237,21 +1591,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 @@ -2358,64 +1697,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. @@ -2453,64 +1734,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. @@ -2575,35 +1798,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? @@ -2643,44 +1837,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. @@ -2719,98 +1875,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. @@ -3440,39 +2504,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 +3067,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -4566,76 +3598,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 51546023..c529f075 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.39.1", + "version": "1.40.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.40.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.39.1", "released": "2026-09-02", diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index a42d888e..c341f898 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 = {} @@ -1497,106 +995,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]: @@ -1761,14 +1159,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 @@ -2069,58 +1459,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?""" @@ -2231,64 +1569,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. @@ -2326,64 +1606,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. @@ -2448,35 +1670,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? @@ -2516,44 +1709,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. @@ -2592,98 +1747,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. @@ -2768,8 +1831,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]: @@ -3293,39 +2354,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] @@ -3898,7 +2927,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -4057,76 +3086,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 adfcfbb7..b6460ab6 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.28.1", + "version": "1.29.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.29.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.28.1", "released": "2026-09-02", diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index c2b8bf3f..09abe8ae 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 = {} @@ -1013,124 +729,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.""" @@ -1140,106 +738,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. @@ -1431,105 +929,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]: @@ -1709,14 +1108,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 @@ -2285,58 +1676,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?""" @@ -2447,64 +1786,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. @@ -2542,64 +1823,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. @@ -2664,35 +1887,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? @@ -2732,44 +1926,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. @@ -2808,98 +1964,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. @@ -2984,8 +2048,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]: @@ -3513,39 +2575,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] @@ -4129,7 +3159,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -4308,76 +3338,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 901ad5d8..3c344cb2 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.3.1", + "version": "3.4.0", "update_interval": 60, "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", @@ -25,6 +25,13 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "3.4.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.3.1", "released": "2026-09-02", diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index 152e6311..cf53054d 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 = {} @@ -1507,105 +1006,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 "". @@ -1846,14 +1246,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. @@ -1875,34 +1267,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() @@ -2222,58 +1586,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?""" @@ -2418,64 +1730,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. @@ -2535,64 +1789,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. @@ -2675,44 +1871,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. @@ -2759,99 +1917,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. @@ -3593,39 +2658,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] @@ -4206,7 +3239,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -4747,76 +3780,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 08b0ebb4..d333f9b4 100644 --- a/plugins/football-scoreboard/test_score_celebration.py +++ b/plugins/football-scoreboard/test_score_celebration.py @@ -75,6 +75,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 89eae801..7e39b762 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.24.1", + "version": "1.25.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,13 @@ } ], "versions": [ + { + "version": "1.25.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.24.1", "released": "2026-09-02", diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 09c56648..12678306 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 = {} @@ -1436,105 +934,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]: @@ -1665,14 +1064,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 @@ -1945,58 +1336,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?""" @@ -2107,64 +1446,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. @@ -2202,64 +1483,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. @@ -2324,35 +1547,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? @@ -2392,44 +1586,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. @@ -2468,98 +1624,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. @@ -2644,8 +1708,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]: @@ -3160,39 +2222,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] @@ -3706,7 +2736,7 @@ def display(self, force_clear=False) -> bool: return False -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -3884,76 +2914,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 1f29007c..75cec242 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.23.1", + "version": "1.24.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,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.23.1", "released": "2026-09-02", diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index fc0b05ed..3b784199 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 = {} @@ -1437,105 +935,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]: @@ -1666,14 +1065,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 @@ -1946,58 +1337,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?""" @@ -2108,64 +1447,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. @@ -2203,64 +1484,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. @@ -2325,35 +1548,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? @@ -2393,44 +1587,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. @@ -2469,98 +1625,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. @@ -2645,8 +1709,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]: @@ -3161,39 +2223,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] @@ -3728,7 +2758,7 @@ def _swrr_schedule(weighted_ids: List[Tuple[str, int]]) -> List[str]: return order -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -3851,76 +2881,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 3bf3cbd1..c9251f47 100644 --- a/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py +++ b/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py @@ -91,6 +91,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 7d73e9d3..4a017d12 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.20.1", + "version": "1.21.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.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. 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.20.1", "released": "2026-09-02", diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index 7f5515c4..96f54ebe 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 = {} @@ -1571,105 +1069,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]: @@ -1818,14 +1217,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 @@ -2204,43 +1595,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. @@ -2255,21 +1609,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 @@ -2375,64 +1714,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. @@ -2470,64 +1751,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. @@ -2592,35 +1815,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? @@ -2660,44 +1854,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. @@ -2736,98 +1892,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. @@ -2912,8 +1976,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]: @@ -3449,39 +2511,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] @@ -4042,7 +3072,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -4572,76 +3602,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 e436fa2d..42c61a18 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.23.1", + "version": "2.24.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.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. 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.23.1", "released": "2026-09-02", diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index 782b767c..60814246 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 = {} @@ -1559,105 +1057,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]: @@ -1817,14 +1216,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 @@ -2202,51 +1593,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. @@ -2261,21 +1607,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 @@ -2382,64 +1713,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. @@ -2477,64 +1750,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. @@ -2599,35 +1814,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? @@ -2667,44 +1853,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. @@ -2743,98 +1891,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. @@ -3463,39 +2519,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] @@ -4057,7 +3081,7 @@ def display(self, force_clear=False) -> bool: return True -class SportsLive(SportsCore): +class SportsLive(SportsLiveSharedMixin, SportsCore): def __init__( self, @@ -4587,76 +3611,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__."""