From 3f0fb9cdd0a4adbf40e2d7263db150a3b2db7faf Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:09:04 -0400 Subject: [PATCH 1/2] fix(scoreboards): retry a logo whose download previously failed Companion to ChuckBuilds/LEDMatrix#512, which is the other half of the same bug. The core caches a failed logo download as a placeholder wearing the real logo's filename. Fixing the downloader alone does not help these plugins, because they never reach it: the loader scans filename variations, finds the stub, and returns it. The downloader is only consulted when *nothing* is found. So the load path itself has to know. A file that is a placeholder stale enough to be worth retrying no longer counts as a hit in the variations scan, and the download gate drops its `not logo_path.exists()` clause -- that clause is what suppressed the download even after the scan had rejected the stub, since the stub sits at exactly logo_path. Verified end to end against real stubs rather than reasoned about. Five of the eleven cached AFL logos here were 384-byte placeholders written in one bad minute; rendering the AFL scoreboard with the core fix in place took COLL.png from 384 bytes to a 38,612-byte 500x500 logo, and the plugin drew Collingwood's mark instead of a grey box for the first time. `_logo_needs_refresh` imports from the core by full path, never as a bare name: six plugins vendor their own logo_downloader.py, and a deferred bare-name import can bind another plugin's copy once the core isolates top-level plugin modules. The first draft did have the bare-name fallback, and check_module_collisions.py caught it. It is also ImportError-guarded, so against a core predating placeholder marking the check is skipped and behaviour is exactly as before. The helper is copied byte-identically into all ten logo loaders, since the sports engine is duplicated per lineage rather than shared. scripts/test_logo_placeholder_refresh.py holds them in step: it fails if a copy diverges, if a loader is missing the check, if the old existence-trusting gate comes back, or if the bare-name import returns. It also pins the behaviour -- real logos untouched, stale placeholders retried, fresh ones left alone so this does not trade a permanent grey box for a request every frame, and an older or throwing core degrading to the previous behaviour rather than breaking loading. Co-Authored-By: Claude Opus 5 --- plugins.json | 36 ++--- plugins/afl-scoreboard/manifest.json | 11 +- plugins/afl-scoreboard/sports.py | 35 ++++- plugins/baseball-scoreboard/logo_manager.py | 37 ++++- plugins/baseball-scoreboard/manifest.json | 17 ++- plugins/baseball-scoreboard/sports.py | 35 ++++- plugins/basketball-scoreboard/manifest.json | 13 +- plugins/basketball-scoreboard/sports.py | 35 ++++- plugins/football-scoreboard/manifest.json | 13 +- plugins/football-scoreboard/sports.py | 35 ++++- plugins/hockey-scoreboard/manifest.json | 11 +- plugins/hockey-scoreboard/sports.py | 35 ++++- plugins/lacrosse-scoreboard/manifest.json | 13 +- plugins/lacrosse-scoreboard/sports.py | 35 ++++- plugins/nrl-scoreboard/manifest.json | 11 +- plugins/nrl-scoreboard/sports.py | 35 ++++- plugins/soccer-scoreboard/manifest.json | 15 +- plugins/soccer-scoreboard/sports.py | 35 ++++- plugins/ufc-scoreboard/manifest.json | 11 +- plugins/ufc-scoreboard/sports.py | 35 ++++- scripts/test_logo_placeholder_refresh.py | 161 ++++++++++++++++++++ 21 files changed, 599 insertions(+), 65 deletions(-) create mode 100644 scripts/test_logo_placeholder_refresh.py diff --git a/plugins.json b/plugins.json index c2cfbe69..66a6e479 100644 --- a/plugins.json +++ b/plugins.json @@ -73,10 +73,10 @@ "plugin_path": "plugins/baseball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.37.0" + "latest_version": "1.37.1" }, { "id": "basketball-scoreboard", @@ -98,10 +98,10 @@ "plugin_path": "plugins/basketball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.26.0" + "latest_version": "1.26.1" }, { "id": "calendar", @@ -237,10 +237,10 @@ "plugin_path": "plugins/football-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "3.1.0" + "latest_version": "3.1.1" }, { "id": "geochron", @@ -332,10 +332,10 @@ "plugin_path": "plugins/hockey-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.22.0", + "latest_version": "1.22.1", "icon": "fas fa-hockey-puck" }, { @@ -356,10 +356,10 @@ "plugin_path": "plugins/lacrosse-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.21.0", + "latest_version": "1.21.1", "icon": "fas fa-baseball-ball" }, { @@ -757,10 +757,10 @@ "plugin_path": "plugins/soccer-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "2.21.0" + "latest_version": "2.21.1" }, { "id": "static-image", @@ -902,10 +902,10 @@ "plugin_path": "plugins/ufc-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-08-05", + "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.8.0", + "latest_version": "1.8.1", "icon": "fas fa-fist-raised" }, { @@ -1048,8 +1048,8 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.19.0", - "last_updated": "2026-08-13" + "latest_version": "1.19.1", + "last_updated": "2026-09-02" }, { "id": "tidbyt-baseball-scoreboard", @@ -1092,10 +1092,10 @@ "plugin_path": "plugins/nrl-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.18.0" + "latest_version": "1.18.1" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 9ba7f314..157f8056 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "afl-scoreboard", "name": "AFL Scoreboard", - "version": "1.19.0", + "version": "1.19.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,13 @@ "afl_upcoming" ], "versions": [ + { + "version": "1.19.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "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.", + "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.19.0", "released": "2026-09-02", @@ -273,7 +280,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py index 94d77e33..b15bf2dd 100644 --- a/plugins/afl-scoreboard/sports.py +++ b/plugins/afl-scoreboard/sports.py @@ -123,6 +123,37 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, return max(low, min(high, seconds)) +def _logo_needs_refresh(logo_file) -> bool: + """True if this file is a placeholder stale enough to retry the real logo. + + A failed logo download is cached as a placeholder wearing the real logo's + filename, so "the file exists" is not proof the logo was ever fetched. + Without this check one transient failure leaves a team a grey box forever. + + Returns False on a core that predates placeholder marking, which keeps the + previous behaviour rather than breaking the load. + """ + # Imported from the core by its full path, never as a bare name: a + # deferred bare-name import can bind another plugin's vendored + # logo_downloader once the core isolates top-level plugin modules. + try: + from src.logo_downloader import ( + PLACEHOLDER_RETRY_SECONDS, + is_placeholder_logo, + placeholder_age_seconds, + ) + except ImportError: + return False + + try: + if not is_placeholder_logo(logo_file): + return False + age = placeholder_age_seconds(logo_file) + return age is None or age >= PLACEHOLDER_RETRY_SECONDS + except Exception: + return False + + class SportsCore(ABC): def __init__( self, @@ -1614,13 +1645,13 @@ def _load_and_resize_logo( for filename in filename_variations: test_path = logo_path.parent / filename - if test_path.exists(): + if test_path.exists() and not _logo_needs_refresh(test_path): actual_logo_path = test_path self.logger.debug(f"Found logo at alternative path: {actual_logo_path}") break # If no variation found, try to download missing logo - if not actual_logo_path and not logo_path.exists(): + if not actual_logo_path: self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.") # AFL is a single league, so logos live in one shared directory. diff --git a/plugins/baseball-scoreboard/logo_manager.py b/plugins/baseball-scoreboard/logo_manager.py index 10912f5d..6f5e32a7 100644 --- a/plugins/baseball-scoreboard/logo_manager.py +++ b/plugins/baseball-scoreboard/logo_manager.py @@ -27,6 +27,37 @@ download_missing_logo = None +def _logo_needs_refresh(logo_file) -> bool: + """True if this file is a placeholder stale enough to retry the real logo. + + A failed logo download is cached as a placeholder wearing the real logo's + filename, so "the file exists" is not proof the logo was ever fetched. + Without this check one transient failure leaves a team a grey box forever. + + Returns False on a core that predates placeholder marking, which keeps the + previous behaviour rather than breaking the load. + """ + # Imported from the core by its full path, never as a bare name: a + # deferred bare-name import can bind another plugin's vendored + # logo_downloader once the core isolates top-level plugin modules. + try: + from src.logo_downloader import ( + PLACEHOLDER_RETRY_SECONDS, + is_placeholder_logo, + placeholder_age_seconds, + ) + except ImportError: + return False + + try: + if not is_placeholder_logo(logo_file): + return False + age = placeholder_age_seconds(logo_file) + return age is None or age >= PLACEHOLDER_RETRY_SECONDS + except Exception: + return False + + class BaseballLogoManager: """Manages logo loading, caching, and downloading for baseball teams.""" @@ -87,17 +118,17 @@ def load_logo(self, team_id: str, team_abbr: str, logo_path: Path, for filename in filename_variations: test_path = logo_path.parent / filename - if test_path.exists(): + if test_path.exists() and not _logo_needs_refresh(test_path): actual_logo_path = test_path self.logger.debug(f"Found logo at alternative path: {actual_logo_path}") break else: # Fallback: just try the original path - if logo_path.exists(): + if logo_path.exists() and not _logo_needs_refresh(logo_path): actual_logo_path = logo_path # If no variation found, try to download missing logo - if not actual_logo_path and not logo_path.exists(): + if not actual_logo_path: self.logger.info(f"Logo not found for {team_abbr} at {logo_path}. Attempting to download.") # Try to download the logo from ESPN API (this will create placeholder if download fails) diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 7b1898e1..f71a76b1 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.37.0", + "version": "1.37.1", "update_interval": 60, "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", @@ -31,6 +31,13 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.37.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "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.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.37.0", "released": "2026-09-02", @@ -347,7 +354,7 @@ { "released": "2026-07-08", "version": "1.14.2", - "notes": "Shrink the Traditional Scoreboard's ball/strike/out circle indicators further (they were still a bit overpowering) and move the batting-team ▲/▼ indicator out of the At Bat column into the header row's empty team-column cell, right next to the inning numbers.", + "notes": "Shrink the Traditional Scoreboard's ball/strike/out circle indicators further (they were still a bit overpowering) and move the batting-team \u25b2/\u25bc indicator out of the At Bat column into the header row's empty team-column cell, right next to the inning numbers.", "ledmatrix_min": "2.0.0" }, { @@ -365,7 +372,7 @@ { "released": "2026-07-07", "version": "1.13.1", - "notes": "Fix the Traditional Scoreboard's At Bat side panel (added in 1.13.0) clipping its ball/strike/out dots off the right edge of the display -- the fit check compared leftover space against a flush-left grid, but the grid is actually centered, so it was eating into the panel's reserved space from the left too. Also account for the Outs row's extra batting-team ▲/▼ arrow, which wasn't factored into the width check at all.", + "notes": "Fix the Traditional Scoreboard's At Bat side panel (added in 1.13.0) clipping its ball/strike/out dots off the right edge of the display -- the fit check compared leftover space against a flush-left grid, but the grid is actually centered, so it was eating into the panel's reserved space from the left too. Also account for the Outs row's extra batting-team \u25b2/\u25bc arrow, which wasn't factored into the width check at all.", "ledmatrix_min": "2.0.0" }, { @@ -407,7 +414,7 @@ { "released": "2026-07-02", "version": "1.7.0", - "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores — spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league.", + "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores \u2014 spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league.", "ledmatrix_min": "2.0.0" }, { @@ -525,7 +532,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index be375a43..f789ca20 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -134,6 +134,37 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, return max(low, min(high, seconds)) +def _logo_needs_refresh(logo_file) -> bool: + """True if this file is a placeholder stale enough to retry the real logo. + + A failed logo download is cached as a placeholder wearing the real logo's + filename, so "the file exists" is not proof the logo was ever fetched. + Without this check one transient failure leaves a team a grey box forever. + + Returns False on a core that predates placeholder marking, which keeps the + previous behaviour rather than breaking the load. + """ + # Imported from the core by its full path, never as a bare name: a + # deferred bare-name import can bind another plugin's vendored + # logo_downloader once the core isolates top-level plugin modules. + try: + from src.logo_downloader import ( + PLACEHOLDER_RETRY_SECONDS, + is_placeholder_logo, + placeholder_age_seconds, + ) + except ImportError: + return False + + try: + if not is_placeholder_logo(logo_file): + return False + age = placeholder_age_seconds(logo_file) + return age is None or age >= PLACEHOLDER_RETRY_SECONDS + except Exception: + return False + + class SportsCore(ABC): def __init__( self, @@ -1562,7 +1593,7 @@ def _load_and_resize_logo( for filename in filename_variations: test_path = logo_path.parent / filename - if test_path.exists(): + if test_path.exists() and not _logo_needs_refresh(test_path): actual_logo_path = test_path self.logger.debug( f"Found logo at alternative path: {actual_logo_path}" @@ -1570,7 +1601,7 @@ def _load_and_resize_logo( break # If no variation found, try to download missing logo - if not actual_logo_path and not logo_path.exists(): + if not actual_logo_path: self.logger.info( f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download." ) diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 98f5088c..67eb1e0b 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.26.0", + "version": "1.26.1", "update_interval": 60, "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", @@ -19,6 +19,13 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.26.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "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.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.26.0", "released": "2026-09-02", @@ -282,7 +289,7 @@ { "released": "2026-07-02", "version": "1.6.0", - "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores — spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league.", + "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores \u2014 spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league.", "ledmatrix_min": "2.0.0" }, { @@ -348,7 +355,7 @@ ], "stars": 0, "downloads": 0, - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "verified": true, "screenshot": "", "display_modes": [ diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 90e4114b..1d9e8b17 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -124,6 +124,37 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, return max(low, min(high, seconds)) +def _logo_needs_refresh(logo_file) -> bool: + """True if this file is a placeholder stale enough to retry the real logo. + + A failed logo download is cached as a placeholder wearing the real logo's + filename, so "the file exists" is not proof the logo was ever fetched. + Without this check one transient failure leaves a team a grey box forever. + + Returns False on a core that predates placeholder marking, which keeps the + previous behaviour rather than breaking the load. + """ + # Imported from the core by its full path, never as a bare name: a + # deferred bare-name import can bind another plugin's vendored + # logo_downloader once the core isolates top-level plugin modules. + try: + from src.logo_downloader import ( + PLACEHOLDER_RETRY_SECONDS, + is_placeholder_logo, + placeholder_age_seconds, + ) + except ImportError: + return False + + try: + if not is_placeholder_logo(logo_file): + return False + age = placeholder_age_seconds(logo_file) + return age is None or age >= PLACEHOLDER_RETRY_SECONDS + except Exception: + return False + + class SportsCore(ABC): TOURNAMENT_ROUND_ORDER: ClassVar[Dict[str, int]] = {"NCG": 0, "F4": 1, "E8": 2, "S16": 3, "R32": 4, "R64": 5, "": 6} @@ -1493,13 +1524,13 @@ def _load_and_resize_logo( for filename in filename_variations: test_path = logo_path.parent / filename - if test_path.exists(): + if test_path.exists() and not _logo_needs_refresh(test_path): actual_logo_path = test_path self.logger.debug(f"Found logo at alternative path: {actual_logo_path}") break # If no variation found, try to download missing logo - if not actual_logo_path and not logo_path.exists(): + if not actual_logo_path: self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.") # Map sport_key to league identifier expected by main downloader diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index c465a699..744ed909 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "3.1.0", + "version": "3.1.1", "update_interval": 60, "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", @@ -25,6 +25,13 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "3.1.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "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.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "3.1.0", "released": "2026-09-01", @@ -382,7 +389,7 @@ "released": "2026-07-10", "version": "2.8.0", "ledmatrix_min_version": "2.0.0", - "notes": "Adaptive layout (beta, opt-in): set layout_mode: \"adaptive\" to scale fonts/logos/regions to any panel size. Default stays \"classic\" — rendering is unchanged unless you opt in; switch back to classic in config to revert without reinstalling. Adaptive mode also applies customization.layout x/y offsets in scroll mode (classic scroll never did). User-configured fonts win over adaptive sizing." + "notes": "Adaptive layout (beta, opt-in): set layout_mode: \"adaptive\" to scale fonts/logos/regions to any panel size. Default stays \"classic\" \u2014 rendering is unchanged unless you opt in; switch back to classic in config to revert without reinstalling. Adaptive mode also applies customization.layout x/y offsets in scroll mode (classic scroll never did). User-configured fonts win over adaptive sizing." }, { "released": "2026-07-08", @@ -629,7 +636,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index 8fb57d2f..9448d4aa 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -128,6 +128,37 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, return max(low, min(high, seconds)) +def _logo_needs_refresh(logo_file) -> bool: + """True if this file is a placeholder stale enough to retry the real logo. + + A failed logo download is cached as a placeholder wearing the real logo's + filename, so "the file exists" is not proof the logo was ever fetched. + Without this check one transient failure leaves a team a grey box forever. + + Returns False on a core that predates placeholder marking, which keeps the + previous behaviour rather than breaking the load. + """ + # Imported from the core by its full path, never as a bare name: a + # deferred bare-name import can bind another plugin's vendored + # logo_downloader once the core isolates top-level plugin modules. + try: + from src.logo_downloader import ( + PLACEHOLDER_RETRY_SECONDS, + is_placeholder_logo, + placeholder_age_seconds, + ) + except ImportError: + return False + + try: + if not is_placeholder_logo(logo_file): + return False + age = placeholder_age_seconds(logo_file) + return age is None or age >= PLACEHOLDER_RETRY_SECONDS + except Exception: + return False + + class _LogoFetcher: """Fetches missing team badges off the data thread, once per team. @@ -1656,7 +1687,7 @@ def _load_and_resize_logo( for filename in filename_variations: test_path = logo_path.parent / filename - if test_path.exists(): + if test_path.exists() and not _logo_needs_refresh(test_path): actual_logo_path = test_path self.logger.debug( f"Found logo at alternative path: {actual_logo_path}" @@ -1664,7 +1695,7 @@ def _load_and_resize_logo( break # If no variation found, try to download missing logo - if not actual_logo_path and not logo_path.exists(): + if not actual_logo_path: self.logger.info( f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download." ) diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 97343929..1b2c61bb 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.22.0", + "version": "1.22.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,13 @@ } ], "versions": [ + { + "version": "1.22.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "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.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.22.0", "released": "2026-09-02", @@ -395,7 +402,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 196f57b8..80aea088 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -121,6 +121,37 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, return max(low, min(high, seconds)) +def _logo_needs_refresh(logo_file) -> bool: + """True if this file is a placeholder stale enough to retry the real logo. + + A failed logo download is cached as a placeholder wearing the real logo's + filename, so "the file exists" is not proof the logo was ever fetched. + Without this check one transient failure leaves a team a grey box forever. + + Returns False on a core that predates placeholder marking, which keeps the + previous behaviour rather than breaking the load. + """ + # Imported from the core by its full path, never as a bare name: a + # deferred bare-name import can bind another plugin's vendored + # logo_downloader once the core isolates top-level plugin modules. + try: + from src.logo_downloader import ( + PLACEHOLDER_RETRY_SECONDS, + is_placeholder_logo, + placeholder_age_seconds, + ) + except ImportError: + return False + + try: + if not is_placeholder_logo(logo_file): + return False + age = placeholder_age_seconds(logo_file) + return age is None or age >= PLACEHOLDER_RETRY_SECONDS + except Exception: + return False + + class SportsCore(ABC): def __init__( self, @@ -1498,7 +1529,7 @@ def _load_and_resize_logo( for filename in filename_variations: test_path = logo_path.parent / filename - if test_path.exists(): + if test_path.exists() and not _logo_needs_refresh(test_path): actual_logo_path = test_path self.logger.debug( f"Found logo at alternative path: {actual_logo_path}" @@ -1506,7 +1537,7 @@ def _load_and_resize_logo( break # If no variation found, try to download missing logo - if not actual_logo_path and not logo_path.exists(): + if not actual_logo_path: self.logger.info( f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download." ) diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index ea3dee68..11a9aefe 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "lacrosse-scoreboard", "name": "Lacrosse Scoreboard", - "version": "1.21.0", + "version": "1.21.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,13 @@ } ], "versions": [ + { + "version": "1.21.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "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.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.21.0", "released": "2026-09-02", @@ -295,7 +302,7 @@ { "released": "2026-07-02", "version": "1.3.0", - "notes": "Add exclude_teams (hide specific teams from the live rotation and recent/final scores — spoiler protection, takes precedence over favorite_teams/show_all_live) and favorite_live_boost (tune how many more turns your favorite's live game gets in the rotation vs other live games, 1 = even rotation, default 2).", + "notes": "Add exclude_teams (hide specific teams from the live rotation and recent/final scores \u2014 spoiler protection, takes precedence over favorite_teams/show_all_live) and favorite_live_boost (tune how many more turns your favorite's live game gets in the rotation vs other live games, 1 = even rotation, default 2).", "ledmatrix_min": "2.0.0" }, { @@ -329,7 +336,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index cc21ef9b..3b99dd32 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -121,6 +121,37 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, return max(low, min(high, seconds)) +def _logo_needs_refresh(logo_file) -> bool: + """True if this file is a placeholder stale enough to retry the real logo. + + A failed logo download is cached as a placeholder wearing the real logo's + filename, so "the file exists" is not proof the logo was ever fetched. + Without this check one transient failure leaves a team a grey box forever. + + Returns False on a core that predates placeholder marking, which keeps the + previous behaviour rather than breaking the load. + """ + # Imported from the core by its full path, never as a bare name: a + # deferred bare-name import can bind another plugin's vendored + # logo_downloader once the core isolates top-level plugin modules. + try: + from src.logo_downloader import ( + PLACEHOLDER_RETRY_SECONDS, + is_placeholder_logo, + placeholder_age_seconds, + ) + except ImportError: + return False + + try: + if not is_placeholder_logo(logo_file): + return False + age = placeholder_age_seconds(logo_file) + return age is None or age >= PLACEHOLDER_RETRY_SECONDS + except Exception: + return False + + class SportsCore(ABC): def __init__( self, @@ -1499,7 +1530,7 @@ def _load_and_resize_logo( for filename in filename_variations: test_path = logo_path.parent / filename - if test_path.exists(): + if test_path.exists() and not _logo_needs_refresh(test_path): actual_logo_path = test_path self.logger.debug( f"Found logo at alternative path: {actual_logo_path}" @@ -1507,7 +1538,7 @@ def _load_and_resize_logo( break # If no variation found, try to download missing logo - if not actual_logo_path and not logo_path.exists(): + if not actual_logo_path: self.logger.info( f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download." ) diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 435960e0..764c4787 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "nrl-scoreboard", "name": "NRL Scoreboard", - "version": "1.18.0", + "version": "1.18.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,13 @@ "nrl_upcoming" ], "versions": [ + { + "version": "1.18.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "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.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "1.18.0", "released": "2026-09-02", @@ -285,7 +292,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index b39e4c03..288be22f 100644 --- a/plugins/nrl-scoreboard/sports.py +++ b/plugins/nrl-scoreboard/sports.py @@ -123,6 +123,37 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, return max(low, min(high, seconds)) +def _logo_needs_refresh(logo_file) -> bool: + """True if this file is a placeholder stale enough to retry the real logo. + + A failed logo download is cached as a placeholder wearing the real logo's + filename, so "the file exists" is not proof the logo was ever fetched. + Without this check one transient failure leaves a team a grey box forever. + + Returns False on a core that predates placeholder marking, which keeps the + previous behaviour rather than breaking the load. + """ + # Imported from the core by its full path, never as a bare name: a + # deferred bare-name import can bind another plugin's vendored + # logo_downloader once the core isolates top-level plugin modules. + try: + from src.logo_downloader import ( + PLACEHOLDER_RETRY_SECONDS, + is_placeholder_logo, + placeholder_age_seconds, + ) + except ImportError: + return False + + try: + if not is_placeholder_logo(logo_file): + return False + age = placeholder_age_seconds(logo_file) + return age is None or age >= PLACEHOLDER_RETRY_SECONDS + except Exception: + return False + + class SportsCore(ABC): def __init__( self, @@ -1633,13 +1664,13 @@ def _load_and_resize_logo( for filename in filename_variations: test_path = logo_path.parent / filename - if test_path.exists(): + if test_path.exists() and not _logo_needs_refresh(test_path): actual_logo_path = test_path self.logger.debug(f"Found logo at alternative path: {actual_logo_path}") break # If no variation found, try to download missing logo - if not actual_logo_path and not logo_path.exists(): + if not actual_logo_path: self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.") # The logo downloader keys its cache folders off sport_key ("nrl"), diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 7b86fa2c..79171ddc 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.21.0", + "version": "2.21.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -26,6 +26,13 @@ "soccer_upcoming" ], "versions": [ + { + "version": "2.21.1", + "released": "2026-09-02", + "ledmatrix_min_version": "3.2.0", + "notes": "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.", + "changelog": "Retry a team logo whose previous download failed, instead of showing a grey box forever. A failed download is cached by the core as a placeholder wearing the real logo's filename; the logo loader scans filename variations, found that stub, and so never called the downloader again. The loader now skips a placeholder that is stale enough to be worth retrying and lets the download run, which also picks up stubs already on disk. The retry is rate-limited by the core (6h), so this does not trade a permanent grey box for a request every frame. Needs a core carrying src.logo_downloader.is_placeholder_logo; against an older core the check is skipped and behaviour is unchanged. Ported byte-identically across every sports lineage." + }, { "version": "2.21.0", "released": "2026-09-02", @@ -272,7 +279,7 @@ { "released": "2026-07-29", "version": "2.5.0", - "notes": "Corrected every team code in TEAMS.md against ESPN's live data — Manchester United is MAN (not MUN), Manchester City MNC (not MCI), Real Madrid RMA, and Ligue 1 had eight wrong codes. The plugin now also says why a league is empty: an unrecognised favorite team logs a warning naming the closest match, while a correct code in a league with no fixtures yet logs the date the season starts.", + "notes": "Corrected every team code in TEAMS.md against ESPN's live data \u2014 Manchester United is MAN (not MUN), Manchester City MNC (not MCI), Real Madrid RMA, and Ligue 1 had eight wrong codes. The plugin now also says why a league is empty: an unrecognised favorite team logs a warning naming the closest match, while a correct code in a league with no fixtures yet logs the date the season starts.", "ledmatrix_min": "2.0.0" }, { @@ -302,7 +309,7 @@ { "released": "2026-07-02", "version": "2.2.0", - "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores — spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league, including custom leagues.", + "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores \u2014 spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league, including custom leagues.", "ledmatrix_min": "2.0.0" }, { @@ -391,7 +398,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-08-13", + "last_updated": "2026-09-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index f4fb8334..9f1f3799 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -130,6 +130,37 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, return max(low, min(high, seconds)) +def _logo_needs_refresh(logo_file) -> bool: + """True if this file is a placeholder stale enough to retry the real logo. + + A failed logo download is cached as a placeholder wearing the real logo's + filename, so "the file exists" is not proof the logo was ever fetched. + Without this check one transient failure leaves a team a grey box forever. + + Returns False on a core that predates placeholder marking, which keeps the + previous behaviour rather than breaking the load. + """ + # Imported from the core by its full path, never as a bare name: a + # deferred bare-name import can bind another plugin's vendored + # logo_downloader once the core isolates top-level plugin modules. + try: + from src.logo_downloader import ( + PLACEHOLDER_RETRY_SECONDS, + is_placeholder_logo, + placeholder_age_seconds, + ) + except ImportError: + return False + + try: + if not is_placeholder_logo(logo_file): + return False + age = placeholder_age_seconds(logo_file) + return age is None or age >= PLACEHOLDER_RETRY_SECONDS + except Exception: + return False + + class SportsCore(ABC): def __init__( self, @@ -1621,13 +1652,13 @@ def _load_and_resize_logo( for filename in filename_variations: test_path = logo_path.parent / filename - if test_path.exists(): + if test_path.exists() and not _logo_needs_refresh(test_path): actual_logo_path = test_path self.logger.debug(f"Found logo at alternative path: {actual_logo_path}") break # If no variation found, try to download missing logo - if not actual_logo_path and not logo_path.exists(): + if not actual_logo_path: self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.") # Map sport_key/league to logo downloader format diff --git a/plugins/ufc-scoreboard/manifest.json b/plugins/ufc-scoreboard/manifest.json index 64ea5990..944f345c 100644 --- a/plugins/ufc-scoreboard/manifest.json +++ b/plugins/ufc-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ufc-scoreboard", "name": "UFC Scoreboard", - "version": "1.8.0", + "version": "1.8.1", "author": "LegoGuy1000", "contributors": [ { @@ -32,6 +32,13 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "version": "1.8.1", + "released": "2026-09-02", + "ledmatrix_min_version": "2.0.0", + "notes": "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.", + "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.8.0", "released": "2026-08-31", @@ -199,7 +206,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-08-05", + "last_updated": "2026-09-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/ufc-scoreboard/sports.py b/plugins/ufc-scoreboard/sports.py index 23ca6ecc..eafcabc4 100644 --- a/plugins/ufc-scoreboard/sports.py +++ b/plugins/ufc-scoreboard/sports.py @@ -120,6 +120,37 @@ def _clamp_seconds(value: Any, fallback: int, low: int = 5, return max(low, min(high, seconds)) +def _logo_needs_refresh(logo_file) -> bool: + """True if this file is a placeholder stale enough to retry the real logo. + + A failed logo download is cached as a placeholder wearing the real logo's + filename, so "the file exists" is not proof the logo was ever fetched. + Without this check one transient failure leaves a team a grey box forever. + + Returns False on a core that predates placeholder marking, which keeps the + previous behaviour rather than breaking the load. + """ + # Imported from the core by its full path, never as a bare name: a + # deferred bare-name import can bind another plugin's vendored + # logo_downloader once the core isolates top-level plugin modules. + try: + from src.logo_downloader import ( + PLACEHOLDER_RETRY_SECONDS, + is_placeholder_logo, + placeholder_age_seconds, + ) + except ImportError: + return False + + try: + if not is_placeholder_logo(logo_file): + return False + age = placeholder_age_seconds(logo_file) + return age is None or age >= PLACEHOLDER_RETRY_SECONDS + except Exception: + return False + + class SportsCore(ABC): def __init__( self, @@ -667,7 +698,7 @@ def _load_and_resize_logo( for filename in filename_variations: test_path = logo_path.parent / filename - if test_path.exists(): + if test_path.exists() and not _logo_needs_refresh(test_path): actual_logo_path = test_path self.logger.debug( f"Found logo at alternative path: {actual_logo_path}" @@ -675,7 +706,7 @@ def _load_and_resize_logo( break # If no variation found, try to download missing logo - if not actual_logo_path and not logo_path.exists(): + if not actual_logo_path: self.logger.info( f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download." ) diff --git a/scripts/test_logo_placeholder_refresh.py b/scripts/test_logo_placeholder_refresh.py new file mode 100644 index 00000000..f2d968f5 --- /dev/null +++ b/scripts/test_logo_placeholder_refresh.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Guards for the placeholder-aware logo load path. + +A failed logo download is cached as a placeholder that wears the real logo's +filename. The scoreboards locate logos by scanning filename variations, so +without a placeholder check they load the grey stub and never consult the +downloader again -- one transient failure costs that team its logo forever. + +The check lives in `_logo_needs_refresh`, which is a *copied* helper: the +sports engine is duplicated per scoreboard lineage rather than shared. These +tests pin the behaviour and hold every copy byte-identical, so a fix to one +cannot silently skip the others. + +Run from the repo root: + + python scripts/test_logo_placeholder_refresh.py +""" + +import ast +import re +import sys +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +PLUGINS = REPO_ROOT / "plugins" + +HELPER_RE = re.compile(r"def _logo_needs_refresh\(.*?\n(?=\n\nclass )", re.S) + + +def copies(): + """Every file that carries the copied logo load path.""" + found = sorted(PLUGINS.glob("*/sports.py")) + extra = PLUGINS / "baseball-scoreboard" / "logo_manager.py" + if extra.is_file(): + found.append(extra) + return [p for p in found + if "If no variation found, try to download missing logo" + in p.read_text(encoding="utf-8")] + + +class HelperCopiesAgree(unittest.TestCase): + def test_every_logo_loader_has_the_helper(self): + missing = [p.relative_to(REPO_ROOT).as_posix() for p in copies() + if "_logo_needs_refresh" not in p.read_text(encoding="utf-8")] + self.assertEqual(missing, [], "logo loaders without a placeholder check") + + def test_all_copies_are_byte_identical(self): + bodies = {} + for path in copies(): + match = HELPER_RE.search(path.read_text(encoding="utf-8")) + self.assertIsNotNone( + match, f"{path.relative_to(REPO_ROOT)}: no _logo_needs_refresh") + bodies.setdefault(match.group(0), []).append( + path.relative_to(REPO_ROOT).as_posix()) + self.assertEqual( + len(bodies), 1, + "copies of _logo_needs_refresh have diverged:\n" + + "\n".join(f" variant {i}: {ps}" + for i, ps in enumerate(bodies.values()))) + + def test_the_download_gate_does_not_trust_mere_existence(self): + """`not logo_path.exists()` in the gate is what caused the bug. + + With it, a placeholder sitting at logo_path suppressed the download + even when the variations scan had rejected it as stale. + """ + offenders = [] + for path in copies(): + text = path.read_text(encoding="utf-8") + if "if not actual_logo_path and not logo_path.exists():" in text: + offenders.append(path.relative_to(REPO_ROOT).as_posix()) + self.assertEqual(offenders, [], "download gate still trusts file existence") + + def test_helper_never_imports_logo_downloader_by_bare_name(self): + """A deferred bare-name import can bind another plugin's vendored copy. + + Six plugins ship their own logo_downloader.py, and the core isolates + top-level plugin modules after the entry point loads. + """ + offenders = [] + for path in copies(): + match = HELPER_RE.search(path.read_text(encoding="utf-8")) + if match and re.search(r"^\s*from logo_downloader import", + match.group(0), re.M): + offenders.append(path.relative_to(REPO_ROOT).as_posix()) + self.assertEqual(offenders, [], "bare-name logo_downloader import in helper") + + +class HelperBehaviour(unittest.TestCase): + """Exercise a copy of the helper against stub core modules.""" + + def _load_helper(self, downloader_module): + source = HELPER_RE.search( + (PLUGINS / "afl-scoreboard" / "sports.py").read_text(encoding="utf-8") + ).group(0) + namespace = {} + exec(compile(ast.parse(source), "", "exec"), namespace) # nosec B102 + saved = {k: sys.modules.get(k) for k in ("src", "src.logo_downloader")} + if downloader_module is not None: + import types + pkg = types.ModuleType("src") + pkg.__path__ = [] + sys.modules["src"] = pkg + sys.modules["src.logo_downloader"] = downloader_module + else: + sys.modules.pop("src.logo_downloader", None) + self.addCleanup(self._restore, saved) + return namespace["_logo_needs_refresh"] + + @staticmethod + def _restore(saved): + for name, module in saved.items(): + if module is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + @staticmethod + def _downloader(is_placeholder, age, retry=6 * 60 * 60): + import types + module = types.ModuleType("src.logo_downloader") + module.PLACEHOLDER_RETRY_SECONDS = retry + module.is_placeholder_logo = lambda _p: is_placeholder + module.placeholder_age_seconds = lambda _p: age + return module + + def test_real_logo_is_never_refreshed(self): + helper = self._load_helper(self._downloader(False, None)) + self.assertFalse(helper(Path("REAL.png"))) + + def test_stale_placeholder_is_refreshed(self): + helper = self._load_helper(self._downloader(True, 7 * 60 * 60)) + self.assertTrue(helper(Path("COLL.png"))) + + def test_fresh_placeholder_is_not_refreshed(self): + """Rate limiting: otherwise this trades a grey box for a request per frame.""" + helper = self._load_helper(self._downloader(True, 60)) + self.assertFalse(helper(Path("COLL.png"))) + + def test_unknown_age_is_refreshed(self): + helper = self._load_helper(self._downloader(True, None)) + self.assertTrue(helper(Path("COLL.png"))) + + def test_older_core_without_the_marker_keeps_old_behaviour(self): + """The core may predate placeholder marking; degrade, do not crash.""" + helper = self._load_helper(None) + self.assertFalse(helper(Path("COLL.png"))) + + def test_a_raising_core_does_not_break_logo_loading(self): + import types + module = types.ModuleType("src.logo_downloader") + module.PLACEHOLDER_RETRY_SECONDS = 100 + module.is_placeholder_logo = lambda _p: (_ for _ in ()).throw(OSError("boom")) + module.placeholder_age_seconds = lambda _p: None + helper = self._load_helper(module) + self.assertFalse(helper(Path("COLL.png"))) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From a7221085ff1a476be84da98eaac1788eb2ed9c68 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:17:15 -0400 Subject: [PATCH 2/2] test(logos): import the helper instead of exec()ing it Codacy flagged the exec() three ways (use-of-exec, plus two command-injection rules for calling it with a non-literal). The intent was to exercise the helper without importing the whole sports.py, which drags in the core -- but writing the extracted source to a temp file and importing it through importlib does that just as well, with no exec() builtin in sight. It is also better as a test: the helper is now a real module with a real filename, so a traceback points somewhere and coverage can see it. The temp directory is cleaned up via addCleanup. Behaviour is unchanged; all 10 tests still pass, and pyflakes and bandit are clean. Co-Authored-By: Claude Opus 5 --- scripts/test_logo_placeholder_refresh.py | 25 ++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/scripts/test_logo_placeholder_refresh.py b/scripts/test_logo_placeholder_refresh.py index f2d968f5..03db09b2 100644 --- a/scripts/test_logo_placeholder_refresh.py +++ b/scripts/test_logo_placeholder_refresh.py @@ -16,9 +16,11 @@ python scripts/test_logo_placeholder_refresh.py """ -import ast +import importlib.util import re +import shutil import sys +import tempfile import unittest from pathlib import Path @@ -91,11 +93,26 @@ class HelperBehaviour(unittest.TestCase): """Exercise a copy of the helper against stub core modules.""" def _load_helper(self, downloader_module): + """Import the helper as a real module, extracted from a plugin copy. + + Written to a temp file and imported through importlib rather than + exec()'d: importing the whole sports.py would drag in the core, and a + normal import keeps this a module with a filename that tracebacks and + coverage can point at. + """ source = HELPER_RE.search( (PLUGINS / "afl-scoreboard" / "sports.py").read_text(encoding="utf-8") ).group(0) - namespace = {} - exec(compile(ast.parse(source), "", "exec"), namespace) # nosec B102 + tmpdir = tempfile.mkdtemp(prefix="logo-helper-") + self.addCleanup(shutil.rmtree, tmpdir, True) + module_path = Path(tmpdir) / "logo_helper_under_test.py" + module_path.write_text(source, encoding="utf-8") + + spec = importlib.util.spec_from_file_location( + "logo_helper_under_test", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + saved = {k: sys.modules.get(k) for k in ("src", "src.logo_downloader")} if downloader_module is not None: import types @@ -106,7 +123,7 @@ def _load_helper(self, downloader_module): else: sys.modules.pop("src.logo_downloader", None) self.addCleanup(self._restore, saved) - return namespace["_logo_needs_refresh"] + return module._logo_needs_refresh @staticmethod def _restore(saved):