Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
59 changes: 42 additions & 17 deletions plugins/ledmatrix-leaderboard/image_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion plugins/ledmatrix-leaderboard/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
8 changes: 7 additions & 1 deletion plugins/ledmatrix-leaderboard/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions plugins/ledmatrix-leaderboard/test_logo_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,43 @@ 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()
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'])

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
Expand Down
Loading