From 9412b93d848df53230ea2854b1da7d632eca29e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 13:38:50 +0000 Subject: [PATCH 1/2] fix(leaderboard): move missing-logo downloads off the render thread _get_team_logo ran from layout construction inside display(), and on a cache miss it invoked download_missing_logo -- a network call on the render thread the Vegas scroll depends on. Missing logos are now backfilled by a new download_missing_logos(), called from update() instead; _get_team_logo only ever reads local files, and a backfilled logo becomes visible on the next rebuild via the existing mtime-keyed cache. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RRsTMmLUcpd8tpN1qciEgC --- plugins.json | 2 +- .../ledmatrix-leaderboard/image_renderer.py | 59 +++++++++++++------ plugins/ledmatrix-leaderboard/manager.py | 6 +- plugins/ledmatrix-leaderboard/manifest.json | 8 ++- .../ledmatrix-leaderboard/test_logo_cache.py | 39 ++++++++++++ 5 files changed, 94 insertions(+), 20 deletions(-) diff --git a/plugins.json b/plugins.json index 6447f170..097232cf 100644 --- a/plugins.json +++ b/plugins.json @@ -412,7 +412,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.1" + "latest_version": "1.3.2" }, { "id": "ledmatrix-flights", diff --git a/plugins/ledmatrix-leaderboard/image_renderer.py b/plugins/ledmatrix-leaderboard/image_renderer.py index b42bef8e..55311cda 100644 --- a/plugins/ledmatrix-leaderboard/image_renderer.py +++ b/plugins/ledmatrix-leaderboard/image_renderer.py @@ -307,8 +307,15 @@ def _cached_prepared_logo(self, path: Optional[str], max_width: int, self._logo_cache[key] = prepared return prepared - def _get_team_logo(self, league: str, team_id: str, team_abbr: str, logo_dir: str) -> Optional[Image.Image]: - """Get team logo from the configured directory, downloading if missing.""" + def _get_team_logo(self, team_abbr: str, logo_dir: str) -> Optional[Image.Image]: + """ + Get team logo from the configured directory. + + Local files only -- this runs from layout construction on the render + thread, and a network download there would block the Vegas scroll. + Missing logos are backfilled by ``download_missing_logos()``, which + the plugin calls from ``update()`` instead. + """ if not team_abbr or not logo_dir: self.logger.debug("Cannot get team logo with missing team_abbr or logo_dir") return None @@ -318,23 +325,42 @@ def _get_team_logo(self, league: str, team_id: str, team_abbr: str, logo_dir: st logo = Image.open(logo_path) self.logger.debug(f"Successfully loaded logo for {team_abbr}") return logo - else: - self.logger.warning(f"Logo not found at path: {logo_path}") - - # Try to download the missing logo - if league: - self.logger.info(f"Attempting to download missing logo for {team_abbr} in league {league}") - success = download_missing_logo(league, team_id, team_abbr, logo_path, None) - if success and os.path.exists(logo_path): - logo = Image.open(logo_path) - self.logger.info(f"Successfully downloaded and loaded logo for {team_abbr}") - return logo - - return None + self.logger.debug(f"Logo not found at path: {logo_path}") + return None except Exception as e: self.logger.error(f"Error loading logo for {team_abbr}: {e}") return None + def download_missing_logos(self, leaderboard_data: List[Dict[str, Any]]) -> None: + """ + Backfill any missing team logo files from the network. + + Intended to be called from ``update()``, off the render thread, so a + download never blocks ``display()``. Once a file lands on disk, + ``_get_team_logo`` picks it up on the next rebuild. + """ + for league_data in leaderboard_data: + league_key = league_data.get('league') + league_config = league_data.get('league_config') or {} + logo_dir = league_config.get('logo_dir') + if not league_key or not logo_dir: + continue + for team in league_data.get('teams', []): + team_abbr = team.get('abbreviation', '') + if not team_abbr: + continue + logo_path = Path(logo_dir, f"{team_abbr}.png") + if os.path.exists(logo_path): + continue + try: + self.logger.info( + "Attempting to download missing logo for %s in league %s", + team_abbr, league_key + ) + download_missing_logo(league_key, team.get('id'), team_abbr, logo_path, None) + except Exception as e: + self.logger.error("Error downloading missing logo for %s: %s", team_abbr, e) + def _get_league_logo(self, league_logo_path: str) -> Optional[Image.Image]: """Get league logo from the configured path.""" if not league_logo_path: @@ -397,8 +423,7 @@ def _build_layout(self, leaderboard_data: List[Dict[str, Any]]) -> Tuple[List[Di str(Path(team_logo_dir, f"{team_text}.png")) if (team_text and team_logo_dir) else None, logo_box, logo_box, - lambda: self._get_team_logo( - league_key, team.get('id'), team_text, team_logo_dir), + lambda: self._get_team_logo(team_text, team_logo_dir), ) width = number_width + text_width + self.TEAM_GAP + self.LOGO_TEXT_GAP diff --git a/plugins/ledmatrix-leaderboard/manager.py b/plugins/ledmatrix-leaderboard/manager.py index cc3400f9..29121b8e 100644 --- a/plugins/ledmatrix-leaderboard/manager.py +++ b/plugins/ledmatrix-leaderboard/manager.py @@ -246,7 +246,11 @@ def update(self, force: bool = False) -> None: self.logger.warning(f"No standings data returned for {league_key}") self.last_update = current_time - + + # Backfill any missing team logos here, off the render thread -- + # display() only ever reads local files, never downloads. + self.image_renderer.download_missing_logos(self.leaderboard_data) + # Clear scroll cache when data updates self.scroll_helper.clear_cache() diff --git a/plugins/ledmatrix-leaderboard/manifest.json b/plugins/ledmatrix-leaderboard/manifest.json index c62dbd0b..c8a93d23 100644 --- a/plugins/ledmatrix-leaderboard/manifest.json +++ b/plugins/ledmatrix-leaderboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-leaderboard", "name": "Sports Leaderboard", - "version": "1.3.1", + "version": "1.3.2", "description": "Displays scrolling leaderboards and standings for multiple sports leagues including NFL, NBA, MLB, NCAA Football, NCAA Basketball, and more", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -31,6 +31,12 @@ "requirements_file": "requirements.txt", "min_ledmatrix_version": "2.0.0", "versions": [ + { + "released": "2026-08-12", + "version": "1.3.2", + "ledmatrix_min_version": "2.0.0", + "notes": "Missing team logos are no longer downloaded from the render path. A network call inside layout construction (display()) could stall the Vegas scroll; the download now happens in update(), and the render path only ever reads local files, picking up a backfilled logo on the next rebuild." + }, { "released": "2026-08-12", "version": "1.3.1", diff --git a/plugins/ledmatrix-leaderboard/test_logo_cache.py b/plugins/ledmatrix-leaderboard/test_logo_cache.py index 47732b0a..890c05cb 100644 --- a/plugins/ledmatrix-leaderboard/test_logo_cache.py +++ b/plugins/ledmatrix-leaderboard/test_logo_cache.py @@ -141,6 +141,45 @@ def load_missing(): check("a warm rebuild is far cheaper", warm * 10 < uncached, "%.2fms cached vs %.2fms uncached" % (warm * 1e3, uncached * 1e3)) + print("\na missing team logo is not downloaded from the render path") + # _get_team_logo runs from layout construction on the render thread; a + # network call there would block the Vegas scroll. Downloading is + # update()'s job (download_missing_logos), not the renderer's. + calls = {'n': 0} + ir.download_missing_logo = lambda *a, **k: calls.__setitem__('n', calls['n'] + 1) or True + r4 = _renderer() + missing_team = str(tmp / 'ZZZ.png') # already unlinked-equivalent: never created here + logo_dir = tmp + result = r4._get_team_logo('NOPE', str(logo_dir)) + check("returns None for a missing local file", result is None) + check("without ever invoking the downloader", calls['n'] == 0, "%d calls" % calls['n']) + + print("\nupdate()'s backfill downloads missing logos and a later render sees them") + downloaded = {'n': 0} + + def fake_download(league, team_id, team_abbr, logo_path, session): + downloaded['n'] += 1 + _png(logo_path) # simulate the downloader writing the file + return True + + ir.download_missing_logo = fake_download + r5 = _renderer() + leaderboard_data = [{ + 'league': 'nfl', + 'league_config': {'logo_dir': str(tmp)}, + 'teams': [{'id': '1', 'abbreviation': 'BFL'}], + }] + bfl_path = tmp / 'BFL.png' + check("logo does not exist yet", not bfl_path.exists()) + r5.download_missing_logos(leaderboard_data) + check("update()'s backfill invoked the downloader once", downloaded['n'] == 1, + "%d calls" % downloaded['n']) + check("and the file now exists locally", bfl_path.exists()) + check("so a later render finds it without downloading", + r5._get_team_logo('BFL', str(tmp)) is not None) + check("still without the render path calling the downloader", + downloaded['n'] == 1, "%d calls" % downloaded['n']) + print("\n%s" % ("FAILED: %d" % len(failures) if failures else "All checks passed")) return 1 if failures else 0 From 34cd19fe3fb0da5668bd846cd2d05e02b7270b50 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 13:41:49 +0000 Subject: [PATCH 2/2] test(leaderboard): drop unused variable in logo cache test Codacy flagged an unused local in the new test coverage. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RRsTMmLUcpd8tpN1qciEgC --- plugins/ledmatrix-leaderboard/test_logo_cache.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugins/ledmatrix-leaderboard/test_logo_cache.py b/plugins/ledmatrix-leaderboard/test_logo_cache.py index 890c05cb..038077b8 100644 --- a/plugins/ledmatrix-leaderboard/test_logo_cache.py +++ b/plugins/ledmatrix-leaderboard/test_logo_cache.py @@ -148,9 +148,7 @@ def load_missing(): calls = {'n': 0} ir.download_missing_logo = lambda *a, **k: calls.__setitem__('n', calls['n'] + 1) or True r4 = _renderer() - missing_team = str(tmp / 'ZZZ.png') # already unlinked-equivalent: never created here - logo_dir = tmp - result = r4._get_team_logo('NOPE', str(logo_dir)) + result = r4._get_team_logo('NOPE', str(tmp)) # no NOPE.png on disk check("returns None for a missing local file", result is None) check("without ever invoking the downloader", calls['n'] == 0, "%d calls" % calls['n'])