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
4 changes: 2 additions & 2 deletions plugins.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"last_updated": "2026-08-11",
"last_updated": "2026-08-12",
"plugins": [
{
"id": "cricket-scoreboard",
Expand Down Expand Up @@ -412,7 +412,7 @@
"last_updated": "2026-08-05",
"verified": true,
"screenshot": "",
"latest_version": "1.3.0"
"latest_version": "1.3.2"
},
{
"id": "ledmatrix-flights",
Expand Down
117 changes: 96 additions & 21 deletions plugins/ledmatrix-leaderboard/image_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ def __init__(self, display_height: int, logger: Optional[logging.Logger] = None,
appearance = appearance or {}
self.pixel_perfect_text = bool(appearance.get('pixel_perfect_text', True))
self.crisp_logos = bool(appearance.get('crisp_logos', True))
# Prepared logos, keyed by file identity and target box. Both halves
# of preparing one are expensive and neither varies between rebuilds:
# Image.open defers the PNG decode to first access, so the decode lands
# inside _prepare_logo's convert(), and a LANCZOS resize follows it. A
# rebuild did that for every team, every time, for files that had not
# changed. On a live rig this ran on the render thread and was the
# largest single contributor to a 3.2s freeze of the scroll.
self._logo_cache: Dict[Any, Optional[Image.Image]] = {}
self.text_outline = bool(appearance.get('text_outline', True))
self.logo_scale = self._clamp_float(appearance.get('logo_scale', 1.0), 0.5, 1.5, 1.0)
self.font_size_override = self._clamp_int(appearance.get('font_size', 0), 0, 32, 0)
Expand Down Expand Up @@ -261,8 +269,53 @@ def _prepare_logo(self, logo: Image.Image, max_width: int,
self.logger.error("Error preparing logo: %s", e)
return None

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."""
# Logos are small; the ceiling only exists so a long-running process with
# many leagues cannot grow this without bound. Clearing wholesale on
# overflow keeps it predictable -- the next rebuild simply repopulates
# what it needs, which is a handful of entries.
_LOGO_CACHE_MAX = 512

def _cached_prepared_logo(self, path: Optional[str], max_width: int,
max_height: int, load) -> Optional[Image.Image]:
"""A prepared logo, decoded and resized at most once per file version.

Keyed on the file's mtime as well as its path, so a logo replaced on
disk (the downloader backfills missing ones) is picked up rather than
served stale forever.

The result is shared, not copied: callers only ever paste from it, and
copying per team would put back a slice of the cost this removes.
"""
stamp = None
if path:
try:
stamp = os.path.getmtime(path)
except OSError:
stamp = None

key = (path, stamp, max_width, max_height, self.crisp_logos)
if stamp is not None and key in self._logo_cache:
return self._logo_cache[key]

prepared = self._prepare_logo(load(), max_width, max_height)

# Only a file that exists is cacheable. Caching a miss would make a
# logo that is downloaded a moment later invisible until restart.
if stamp is not None:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if len(self._logo_cache) >= self._LOGO_CACHE_MAX:
self._logo_cache.clear()
self._logo_cache[key] = prepared
return prepared

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 @@ -272,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 @@ -332,8 +404,9 @@ def _build_layout(self, leaderboard_data: List[Dict[str, Any]]) -> Tuple[List[Di
if league_data.get('is_tournament') and league_key in ('ncaam_basketball', 'ncaaw_basketball'):
if os.path.exists(self.MARCH_MADNESS_LOGO_PATH):
league_logo_path = self.MARCH_MADNESS_LOGO_PATH
league_logo = self._prepare_logo(
self._get_league_logo(league_logo_path), self.LEAGUE_LOGO_WIDTH, height
league_logo = self._cached_prepared_logo(
league_logo_path or None, self.LEAGUE_LOGO_WIDTH, height,
lambda: self._get_league_logo(league_logo_path),
)

teams = []
Expand All @@ -345,10 +418,12 @@ def _build_layout(self, leaderboard_data: List[Dict[str, Any]]) -> Tuple[List[Di
team_text = team.get('abbreviation', '')
text_width = self._text_advance(team_text, self.fonts['large'])

team_logo = self._prepare_logo(
self._get_team_logo(league_key, team.get('id'), team_text,
league_config.get('logo_dir')),
logo_box, logo_box
team_logo_dir = league_config.get('logo_dir')
team_logo = self._cached_prepared_logo(
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(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
14 changes: 13 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.0",
"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,18 @@
"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",
"ledmatrix_min_version": "2.0.0",
"notes": "Decode and resize each team logo once instead of once per rebuild. Image.open defers the PNG decode to first access, so it landed inside _prepare_logo's convert(), with a LANCZOS resize after it, for every team on every rebuild of files that had not changed. Measured on real assets: 25 logos cost 276ms uncached and 0.3ms warm. This matters because leaderboard scroll content is built on the render thread -- the adapter's capture path needs the shared canvas -- so the cost lands directly on the Vegas scroll. Cached on path plus mtime, so a logo the downloader backfills is picked up rather than served stale, and a missing one is retried rather than remembered as absent."
},
{
"version": "1.3.0",
"released": "2026-08-05",
Expand Down
Loading
Loading