From ebd264795d48e9cf3159daf31a99c4d675268887 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 23:16:58 +0000 Subject: [PATCH 1/5] feat(baseball-scoreboard): team-color grid, clearer at-bat labels, player card (v1.19.0) Three enhancements to the baseball plugin: 1. Traditional scoreboard: subtle team-color row tint (~12% brightness wash) plus a solid left-edge accent strip per team, reusing the already-fetched away/home ESPN colors. Gated by a new show_team_color_backgrounds toggle (default on; requires use_team_colors). Text stays legible via its black outline. 2. Pitcher/Batter screen: labels spelled out ("Pitcher:" / "Batter:") so "B" is no longer ambiguous with the grid's Balls indicator. 3. New Player Card screen (show_player_card, MLB & NCAA): rotates in a masters-style card for the current batter/pitcher with headshot, jersey number, position, bat/throw, and season stats (AVG/HR/RBI for hitters, ERA/W-L/K for pitchers). Bio + headshot fetched from ESPN's athlete API and cached (memory + disk); the headshot hides on tiny panels and the card degrades to text-only when unavailable. Skipped for MiLB (no ESPN player data). All new code lives in existing files (no new bare-name modules, per the cross-plugin collision rule). Adds config schema (grid toggle, per-league show_player_card, player_card customization block), manager plumbing, docs, and tests (test_player_card.py + grid-tint tests). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01R1z28MrGx56x6eiJvibDg9 --- plugins.json | 6 +- plugins/baseball-scoreboard/.gitignore | 3 + plugins/baseball-scoreboard/CHANGELOG.md | 9 + plugins/baseball-scoreboard/README.md | 62 ++- plugins/baseball-scoreboard/baseball.py | 527 +++++++++++++++++- .../baseball-scoreboard/config_schema.json | 102 ++++ plugins/baseball-scoreboard/data_sources.py | 89 +++ plugins/baseball-scoreboard/logo_manager.py | 65 +++ plugins/baseball-scoreboard/manager.py | 1 + plugins/baseball-scoreboard/manifest.json | 10 +- .../baseball-scoreboard/test_player_card.py | 367 ++++++++++++ .../test_traditional_scoreboard.py | 47 ++ 12 files changed, 1271 insertions(+), 17 deletions(-) create mode 100644 plugins/baseball-scoreboard/test_player_card.py diff --git a/plugins.json b/plugins.json index c54ca1a4..2bc33f4f 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-07-08", + "last_updated": "2026-07-09", "plugins": [ { "id": "7-segment-clock", @@ -46,10 +46,10 @@ "plugin_path": "plugins/baseball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-08", + "last_updated": "2026-07-09", "verified": true, "screenshot": "", - "latest_version": "1.18.0" + "latest_version": "1.19.0" }, { "id": "basketball-scoreboard", diff --git a/plugins/baseball-scoreboard/.gitignore b/plugins/baseball-scoreboard/.gitignore index 979acecd..30663522 100644 --- a/plugins/baseball-scoreboard/.gitignore +++ b/plugins/baseball-scoreboard/.gitignore @@ -144,3 +144,6 @@ Thumbs.db *.log config.json *.pem + +# Downloaded player headshots (cached at runtime, not committed) +assets/headshots/ diff --git a/plugins/baseball-scoreboard/CHANGELOG.md b/plugins/baseball-scoreboard/CHANGELOG.md index 88f4606b..99286bb8 100644 --- a/plugins/baseball-scoreboard/CHANGELOG.md +++ b/plugins/baseball-scoreboard/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.19.0] - 2026-07-09 + +### Added +- **Player Card screen**: A new dedicated full-screen card (`show_player_card`, per league; MLB & NCAA Baseball) that periodically rotates into the live display for the current batter — and optionally the pitcher — with a headshot image, jersey number, position, bat/throw hand, and season stats (AVG/HR/RBI for hitters, ERA/W-L/K for pitchers). Bio + headshot are fetched from ESPN's athlete API and cached (in-memory + on disk). On tiny panels (e.g. 64×32) the headshot is hidden and a compact text card is shown; the card degrades to text-only whenever a headshot is unavailable and is skipped entirely for MiLB (no ESPN player data). Tunable under Customization → Player Card. +- **Team-color grid tint**: The Traditional Scoreboard now paints a subtle (~12% brightness) wash of each team's real ESPN color behind its row, plus a solid team-color accent strip on the left edge (toggle `show_team_color_backgrounds`, default on; requires `use_team_colors`). + +### Changed +- **Clearer Pitcher/Batter labels**: The pitcher/batter screen now spells out `Pitcher:` and `Batter:` instead of the ambiguous `P:` / `B:` (the latter clashed visually with the grid's Balls indicator). + ## [1.0.4] - 2025-10-20 ### Added diff --git a/plugins/baseball-scoreboard/README.md b/plugins/baseball-scoreboard/README.md index d14e6410..606ee3c5 100644 --- a/plugins/baseball-scoreboard/README.md +++ b/plugins/baseball-scoreboard/README.md @@ -218,6 +218,7 @@ screen): | `font` | `"9x15.bdf"` | Font for all text on this screen. The default is a clean, bold bitmap font sized to fit the display; a fixed-size `.bdf` font always renders at its own native pixel size (with an automatic fallback to a smaller sibling font if your display is too small to fit it) rather than scaling to `font_size`. Use a scalable `.ttf` font (e.g. `"press_start"` for a chunkier 8-bit retro look) if you want `font_size` to directly control the size. | | `font_size` | `24` | Maximum font size cap, for scalable `.ttf` fonts only (ignored by fixed-size `.bdf` fonts like the default). The screen auto-fits the largest text that still leaves room for the ball/strike/out column, so the default effectively means "as big as the display allows" — lower it to force a smaller, more consistent size. | | `use_team_colors` | `true` | Color each team's abbreviation with their real ESPN team colors (brightness-adjusted for legibility on black) instead of a flat `text_color`. | +| `show_team_color_backgrounds` | `true` | Tint each team's row with a subtle (~12% brightness) wash of their real ESPN color, plus a solid team-color accent strip on the left edge — a colorful ballpark look. Requires `use_team_colors`; a team with no ESPN color simply gets no tint. Text stays legible because it's drawn with a black outline over the wash. | | `show_logos` | `true` | Show a small team logo beside each abbreviation when there's spare width (see "How it works" above). | | `show_dividers` | `true` | Draw thin 1px grid lines between innings, rows, and the R/H/E columns for readability. | | `highlight_winner` | `true` | On a final game, color the winning team's run total in `winner_color` so the winner is obvious at a glance instead of having to compare both R values yourself. Pairs naturally with `game_scope: "recent"`. No effect on live games. | @@ -251,7 +252,9 @@ the game is final (a simple "check the final score" use case): A dedicated full-screen view showing the current at-bat's pitcher, batter, and a short code for the most recently completed play (`1B`, `HR`, `K`, `BB`, etc.), replacing the normal scorebug for a few -seconds at a time. Available for **MLB and NCAA Baseball only**, and +seconds at a time. The pitcher and batter lines are labeled in full — +`Pitcher: G. Cole` / `Batter: J. Soto` — so there's no ambiguity with +the grid's `B` (Balls) indicator. Available for **MLB and NCAA Baseball only**, and **live games only** — this data only exists during an actual live at-bat, so unlike the Traditional Scoreboard there's no `game_scope` option (nothing analogous exists for a final or upcoming game). @@ -297,6 +300,63 @@ All of the following live under `customization.at_bat_info`: | `batter_color` | `[255, 255, 0]` | `[R, G, B]` for the batter line when `use_team_colors` is off or unavailable. | | `last_play_color` | `[0, 255, 255]` | `[R, G, B]` for the last-play code line (always this flat color — there's no "team" a play code belongs to). | +## Player Card Screen + +A dedicated full-screen "baseball card" that rotates in for the current +batter (and optionally the pitcher): a **headshot image**, **jersey +number**, **position**, **bat/throw hand**, and **season stats** — +`AVG` / `HR` / `RBI` for hitters, `ERA` / `W-L` / `K` for pitchers. +The headshot and bio are fetched from ESPN's athlete API and cached +(in memory and on disk under `assets/headshots/`, which is gitignored). +Available for **MLB and NCAA Baseball only** and **live games only** +(the pitcher/batter are only known during a live at-bat); MiLB is +skipped because it has no ESPN player data. + +The layout adapts to your panel size: on larger displays the headshot +sits on the left with a team-colored frame and the text stacks beside +it; on tiny panels (e.g. 64×32) the headshot is hidden and a compact +two-line text card is shown instead. If a headshot can't be loaded the +card renders text-only, and if no bio is available yet the card is +simply skipped that rotation (never shown blank). + +### Enabling it + +Turn it on per league under that league's `display_options`: + +```json +{ + "mlb": { + "display_options": { + "show_player_card": true + } + } +} +``` + +The same flag exists under `ncaa_baseball.display_options`, off by +default. Enabling it works alongside (and independently of) the +Pitcher/Batter and Traditional Scoreboard screens — each rotates in on +its own schedule. + +### Toggles and customization + +All of the following live under `customization.player_card`: + +| Option | Default | What it does | +|---|---|---| +| `show_batter` | `true` | Show a card for the current batter. | +| `show_pitcher` | `false` | Also show a card for the current pitcher (the batter is preferred when both are available). | +| `favorites_only` | `false` | Only rotate the card in for games involving one of this league's `favorite_teams`. Has no effect if `favorite_teams` is empty. | +| `dwell_seconds` | `6` | How many seconds the card stays on screen each time it rotates in. | +| `interval_seconds` | `40` | How often (in seconds) the card rotates in. | +| `font` | `"9x15.bdf"` | Font for the card's text; auto-fits within the space beside the headshot. Use a scalable `.ttf` font (e.g. `"press_start"`) if you want `font_size` to directly control the size. | +| `font_size` | `24` | Maximum font size cap, for scalable `.ttf` fonts only (ignored by fixed-size `.bdf` fonts like the default). | +| `use_team_colors` | `true` | Color the player's name with their real ESPN team color instead of the flat `text_color`. | +| `use_team_colors_border` | `true` | Draw the headshot's frame in the player's team color instead of the flat `border_color`. | +| `border_color` | `[255, 200, 0]` | `[R, G, B]` for the headshot frame when `use_team_colors_border` is off or the team color is unavailable. | +| `text_color` | `[255, 255, 255]` | `[R, G, B]` for the name (when team colors are off) and the jersey/position/bat-throw line. | +| `stat_color` | `[0, 220, 255]` | `[R, G, B]` for the season-stats line. | + ## Team Abbreviations ### MLB Teams diff --git a/plugins/baseball-scoreboard/baseball.py b/plugins/baseball-scoreboard/baseball.py index 782cb2b6..63a35993 100644 --- a/plugins/baseball-scoreboard/baseball.py +++ b/plugins/baseball-scoreboard/baseball.py @@ -86,6 +86,74 @@ def _get_current_pitcher_batter( return None, None +def _hand_abbr(value: Any) -> Optional[str]: + """Normalize an ESPN bats/throws field (dict or string) to a short code + like 'R'/'L'/'S'. Returns None when absent.""" + if isinstance(value, dict): + return value.get("abbreviation") or value.get("displayValue") or value.get("type") + if isinstance(value, str) and value: + return value[:1].upper() + return None + + +def _build_athlete_info_map(rosters: Optional[List[Dict]]) -> Dict[str, Dict]: + """Join both teams' rosters into {athlete_id: {name, jersey, position, + headshot_url, bat, throw, team_id, team_abbr}}. + + Richer sibling of _build_athlete_name_map -- carries the extra fields the + player card needs (jersey/position/headshot + which team the athlete is + on, so the card can pick the correct team color). Kept separate so the + existing name-only map (and its tests) stay unchanged.""" + info: Dict[str, Dict] = {} + for team_roster in rosters or []: + team = team_roster.get("team", {}) or {} + team_id = str(team.get("id", "") or "") + team_abbr = team.get("abbreviation") or "" + for entry in team_roster.get("roster", []) or []: + athlete = entry.get("athlete", {}) or {} + athlete_id = str(athlete.get("id", "") or "") + if not athlete_id: + continue + position = athlete.get("position") or {} + if isinstance(position, dict): + position = position.get("abbreviation") or position.get("displayName") or "" + headshot = (athlete.get("headshot") or {}).get("href") + info[athlete_id] = { + "name": athlete.get("shortName") or athlete.get("fullName") or "", + "jersey": athlete.get("jersey"), + "position": position or "", + "headshot_url": headshot, + "bat": _hand_abbr(athlete.get("bats")), + "throw": _hand_abbr(athlete.get("throws")), + "team_id": team_id, + "team_abbr": team_abbr, + } + return info + + +def _get_current_pitcher_batter_ids( + plays: Optional[List[Dict]], +) -> Tuple[Optional[str], Optional[str]]: + """Scan plays backward for the most recent play naming a pitcher/batter, + returning their athlete IDs (parallel to _get_current_pitcher_batter, which + returns names). The card needs the IDs to fetch bios/headshots.""" + for play in reversed(plays or []): + pitcher_id = None + batter_id = None + for participant in play.get("participants") or []: + role = participant.get("type") + athlete_id = str(participant.get("athlete", {}).get("id", "") or "") + if not athlete_id: + continue + if role == "pitcher": + pitcher_id = athlete_id + elif role == "batter": + batter_id = athlete_id + if pitcher_id or batter_id: + return pitcher_id, batter_id + return None, None + + def _map_play_type(play: Dict) -> Optional[str]: """Map a single play to a short code, or None on no confident match.""" play_type = (play.get("type") or {}).get("type", "") @@ -197,6 +265,7 @@ def __init__( self.show_count = self.mode_config.get("show_count", True) self.show_pitcher_batter = self.mode_config.get("show_pitcher_batter", False) self.show_last_play = self.mode_config.get("show_last_play", False) + self.show_player_card = self.mode_config.get("show_player_card", False) self.show_traditional_scoreboard = self.mode_config.get("show_traditional_scoreboard", False) self.show_series_summary = self.mode_config.get("show_series_summary", False) self.data_source = ESPNDataSource(logger) @@ -794,6 +863,35 @@ def cell_value(linescore: List[str], inning_num: int) -> str: away_y = header_y + row_h home_y = away_y + row_h + # Subtle team-color wash behind each team's row, plus a solid + # accent strip on the left edge -- drawn FIRST (before text, + # dividers, logos, and the At Bat panel) so everything else + # prints cleanly on top. The wash is dimmed to ~12% brightness so + # the black-outlined white text stays legible over it; the strip + # uses the full (already brightness-clamped) team color. Gated by + # use_team_colors (shared with the abbreviation coloring) and a + # dedicated show_team_color_backgrounds toggle; a team with no + # ESPN color simply gets no wash rather than a wrong one. + if use_team_colors and cfg.get("show_team_color_backgrounds", True): + # A 1px strip would collide with a flush-left abbreviation + # (no logo); keep it 1px there, otherwise scale with the font. + strip_w = max(1, char_w // 3) if logo_w else 1 + grid_x_end = x_offset + content_w - 1 + for row_y, tc in ( + (away_y, game.get("away_team_color")), + (home_y, game.get("home_team_color")), + ): + if not tc: + continue + tint = tuple(max(0, int(c * 0.12)) for c in tc) + draw.rectangle( + [x_offset, row_y, grid_x_end, row_y + row_h - 1], fill=tint + ) + draw.rectangle( + [x_offset, row_y, x_offset + strip_w - 1, row_y + row_h - 1], + fill=tuple(tc), + ) + # Inning-number header + per-inning score rows. for col in range(num_cols): inning_num = start_inning + col @@ -977,12 +1075,24 @@ def __init__( # Dedicated at-bat-info rotating screen timing state. self._at_bat_screen_last_shown = 0.0 self._at_bat_screen_showing_until = 0.0 + # Player-card rotating screen: timing state + a resolved-bio cache + # (keyed by athlete id) and a per-id fetch throttle. Bios change rarely + # so they're cached via the core cache_manager with a long TTL as well. + self._player_card_last_shown = 0.0 + self._player_card_showing_until = 0.0 + self._player_bio_cache: Dict[str, Optional[Dict]] = {} + self._player_bio_last_attempt: Dict[str, float] = {} + self.player_bio_update_interval = self.mode_config.get( + "player_bio_update_interval", 300 + ) + self._headshot_mgr = None # lazily created in the render path def update(self): super().update() if self.test_mode: return - if not (self.show_pitcher_batter or self.show_last_play): + pbp_wanted = self.show_pitcher_batter or self.show_last_play or self.show_player_card + if not pbp_wanted: return if not self.espn_summary_sport_league: return @@ -999,6 +1109,19 @@ def update(self): self._play_by_play_last_attempt[game_id] = now self._fetch_play_by_play(game_id) + # Lazily enrich the current batter/pitcher with a full bio (season + # stats) for the player card -- only for the two current athlete IDs, + # throttled, so we never hammer the athlete API from the render loop. + if self.show_player_card: + pbp = self._play_by_play_cache.get(game_id) or {} + for pid in (pbp.get("batter_id"), pbp.get("pitcher_id")): + if not pid: + continue + last = self._player_bio_last_attempt.get(pid, 0) + if now - last >= self.player_bio_update_interval: + self._player_bio_last_attempt[pid] = now + self._fetch_player_bio(pid) + self._prune_stale_play_by_play() def _prune_stale_play_by_play(self) -> None: @@ -1058,11 +1181,21 @@ def fetch(): athlete_names = _build_athlete_name_map(rosters) pitcher, batter = _get_current_pitcher_batter(plays, athlete_names) last_play_code = _get_last_play_code(plays) + # Extra plumbing for the player card: the athlete IDs of the + # current pitcher/batter plus their roster info (jersey, position, + # headshot, team). Cheap to compute here alongside the names; the + # card's season-stats bio is fetched lazily/separately. + info_map = _build_athlete_info_map(rosters) + pitcher_id, batter_id = _get_current_pitcher_batter_ids(plays) if pitcher or batter or last_play_code: self._play_by_play_cache[game_id] = { "pitcher": pitcher, "batter": batter, "last_play_code": last_play_code, + "pitcher_id": pitcher_id, + "batter_id": batter_id, + "pitcher_info": info_map.get(pitcher_id) if pitcher_id else None, + "batter_info": info_map.get(batter_id) if batter_id else None, } else: self.logger.debug( @@ -1072,16 +1205,123 @@ def fetch(): except Exception as e: self.logger.error(f"Error parsing play-by-play for game {game_id}: {e}") + def _fetch_player_bio(self, player_id: str) -> None: + """Fetch and cache a player's bio + season stats for the player card, + off-thread with a bounded timeout (same non-blocking pattern as + _fetch_play_by_play). Results persist in-memory and, via the core + cache_manager, across restarts. Stores None on a definitive miss so we + don't refetch a player with no ESPN athlete record every interval.""" + if not player_id or not self.espn_summary_sport_league: + return + + # Serve from the durable cache first (bios rarely change). + cache_key = f"baseball_player_{player_id}" + if player_id not in self._player_bio_cache and self.cache_manager is not None: + try: + cached = self.cache_manager.get(cache_key) + if cached is not None: + self._player_bio_cache[player_id] = cached or None + return + except Exception: + pass + + import threading + import queue + + sport, league = self.espn_summary_sport_league + result_queue = queue.Queue() + + def fetch(): + try: + data = self.data_source.fetch_player_details(sport, league, player_id) + result_queue.put(("success", data)) + except Exception as e: + result_queue.put(("error", e)) + + thread = threading.Thread(target=fetch, daemon=True) + thread.start() + + try: + result_type, result_data = result_queue.get(timeout=2.0) + except queue.Empty: + self.logger.debug(f"Player bio fetch timed out for {player_id} (non-blocking)") + return + + if result_type != "success": + self.logger.debug(f"Player bio fetch failed for {player_id}: {result_data}") + return + + self._player_bio_cache[player_id] = result_data or None + if self.cache_manager is not None: + try: + self.cache_manager.set( + cache_key, result_data or {}, ttl=self.player_bio_update_interval + ) + except Exception: + pass + # Sample data cycled through in test_mode so the at-bat info screen's # real draw path (font/color/dwell/interval config) gets exercised # offline, with zero network calls. _TEST_MODE_AT_BAT_SAMPLES = [ - {"pitcher": "G. Cole", "batter": "J. Soto", "last_play_code": "1B"}, - {"pitcher": "G. Cole", "batter": "A. Judge", "last_play_code": "K"}, - {"pitcher": "Y. Yamamoto", "batter": "F. Freeman", "last_play_code": "HR"}, - {"pitcher": "Y. Yamamoto", "batter": "M. Betts", "last_play_code": "BB"}, + { + "pitcher": "G. Cole", "batter": "J. Soto", "last_play_code": "1B", + "pitcher_id": "p_cole", "batter_id": "b_soto", + "pitcher_info": {"name": "G. Cole", "jersey": "45", "position": "SP", + "bat": "R", "throw": "R", "team_id": "away"}, + "batter_info": {"name": "J. Soto", "jersey": "22", "position": "RF", + "bat": "L", "throw": "L", "team_id": "home"}, + }, + { + "pitcher": "G. Cole", "batter": "A. Judge", "last_play_code": "K", + "pitcher_id": "p_cole", "batter_id": "b_judge", + "pitcher_info": {"name": "G. Cole", "jersey": "45", "position": "SP", + "bat": "R", "throw": "R", "team_id": "away"}, + "batter_info": {"name": "A. Judge", "jersey": "99", "position": "CF", + "bat": "R", "throw": "R", "team_id": "home"}, + }, + { + "pitcher": "Y. Yamamoto", "batter": "F. Freeman", "last_play_code": "HR", + "pitcher_id": "p_yama", "batter_id": "b_freeman", + "pitcher_info": {"name": "Y. Yamamoto", "jersey": "18", "position": "SP", + "bat": "R", "throw": "R", "team_id": "home"}, + "batter_info": {"name": "F. Freeman", "jersey": "5", "position": "1B", + "bat": "L", "throw": "R", "team_id": "away"}, + }, + { + "pitcher": "Y. Yamamoto", "batter": "M. Betts", "last_play_code": "BB", + "pitcher_id": "p_yama", "batter_id": "b_betts", + "pitcher_info": {"name": "Y. Yamamoto", "jersey": "18", "position": "SP", + "bat": "R", "throw": "R", "team_id": "home"}, + "batter_info": {"name": "M. Betts", "jersey": "50", "position": "SS", + "bat": "R", "throw": "R", "team_id": "away"}, + }, ] + # Stub bios (season stats) keyed by the test athlete ids above, so the + # player card's full render path runs offline. Headshots resolve to None + # without a network -- exercising the text-only card layout. + _TEST_MODE_PLAYER_BIOS = { + "b_soto": {"display_name": "Juan Soto", "jersey": "22", "position": "RF", + "bat": "L", "throw": "L", "headshot_url": None, + "stats": {"AVG": ".288", "HR": "41", "RBI": "109"}}, + "b_judge": {"display_name": "Aaron Judge", "jersey": "99", "position": "CF", + "bat": "R", "throw": "R", "headshot_url": None, + "stats": {"AVG": ".322", "HR": "58", "RBI": "144"}}, + "b_freeman": {"display_name": "Freddie Freeman", "jersey": "5", "position": "1B", + "bat": "L", "throw": "R", "headshot_url": None, + "stats": {"AVG": ".282", "HR": "22", "RBI": "89"}}, + "b_betts": {"display_name": "Mookie Betts", "jersey": "50", "position": "SS", + "bat": "R", "throw": "R", "headshot_url": None, + "stats": {"AVG": ".289", "HR": "19", "RBI": "75"}}, + "p_cole": {"display_name": "Gerrit Cole", "jersey": "45", "position": "SP", + "bat": "R", "throw": "R", "headshot_url": None, + "stats": {"ERA": "3.41", "W": "8", "L": "5", "SO": "165"}}, + "p_yama": {"display_name": "Yoshinobu Yamamoto", "jersey": "18", "position": "SP", + "bat": "R", "throw": "R", "headshot_url": None, + "stats": {"ERA": "2.90", "W": "11", "L": "4", "SO": "180"}}, + } + # After this many innings, reset the simulated game back to a fresh # start rather than climbing forever -- most real games are 9 innings; # this lets the occasional extra-inning case show without the fixture @@ -1140,14 +1380,18 @@ def _test_mode_update(self): int(self.current_game["away_score"]) + 1 ) - if self.show_pitcher_batter or self.show_last_play: + if self.show_pitcher_batter or self.show_last_play or self.show_player_card: game_id = self.current_game.get("id", "test") sample_index = self.current_game["inning"] % len( self._TEST_MODE_AT_BAT_SAMPLES ) - self._play_by_play_cache[game_id] = self._TEST_MODE_AT_BAT_SAMPLES[ - sample_index - ] + sample = self._TEST_MODE_AT_BAT_SAMPLES[sample_index] + self._play_by_play_cache[game_id] = sample + # Seed the bio cache so the player card renders offline. + if self.show_player_card: + for pid in (sample.get("batter_id"), sample.get("pitcher_id")): + if pid and pid not in self._player_bio_cache: + self._player_bio_cache[pid] = self._TEST_MODE_PLAYER_BIOS.get(pid) def _maybe_draw_at_bat_info_screen(self, game: Dict, force_clear: bool = False) -> bool: """Rotate in the dedicated pitcher/batter/last-play screen if it's @@ -1242,9 +1486,9 @@ def _draw_at_bat_info_screen( lines = [] if self.show_pitcher_batter and pbp.get("pitcher"): - lines.append((f"P: {pbp['pitcher']}", pitcher_color)) + lines.append((f"Pitcher: {pbp['pitcher']}", pitcher_color)) if self.show_pitcher_batter and pbp.get("batter"): - lines.append((f"B: {pbp['batter']}", batter_color)) + lines.append((f"Batter: {pbp['batter']}", batter_color)) if self.show_last_play and pbp.get("last_play_code"): lines.append((pbp["last_play_code"], last_play_color)) @@ -1288,10 +1532,271 @@ def _draw_at_bat_info_screen( except Exception as e: self.logger.error(f"Error drawing at-bat info screen: {e}", exc_info=True) + def _get_headshot_manager(self): + """Lazily create the headshot loader (a BaseballLogoManager, which + already has the disk/memory-cache + download machinery). Returns None + if it can't be constructed so the card degrades to text-only.""" + if self._headshot_mgr is None: + try: + from logo_manager import BaseballLogoManager + self._headshot_mgr = BaseballLogoManager( + self.display_manager, self.logger, self.sport_key + ) + except Exception as e: + self.logger.debug(f"Could not create headshot manager: {e}") + return None + return self._headshot_mgr + + def _player_card_team_color(self, game: Dict, info: Optional[Dict]): + """Pick the team color for a card's border/name from the athlete's + roster team_id (authoritative), cross-checked against the game's + home/away ids; fall back to inning-half inference, then None.""" + if info: + team_id = str(info.get("team_id") or "") + if team_id and team_id == str(game.get("home_id") or ""): + return game.get("home_team_color") + if team_id and team_id == str(game.get("away_id") or ""): + return game.get("away_team_color") + return None + + def _maybe_draw_player_card_screen(self, game: Dict, force_clear: bool = False) -> bool: + """Rotate in the masters-style player card (headshot + bio + season + stats) for the current batter/pitcher if it's due. Returns True if it + drew. Skips silently for MiLB (no ESPN athlete ids) and whenever a + resolved bio isn't available yet, so a blank card is never shown.""" + if not self.show_player_card: + return False + if not self.espn_summary_sport_league: + return False # MiLB: no ESPN athlete ids + + pbp = self._play_by_play_cache.get(game.get("id")) + if not pbp: + return False + + card_cfg = self.config.get("customization", {}).get("player_card", {}) + show_batter = card_cfg.get("show_batter", True) + show_pitcher = card_cfg.get("show_pitcher", False) + + # Resolve which player to show (batter takes priority) -- requires both + # roster info and a fetched bio, else there's nothing worth a card. + candidate = None + if show_batter and pbp.get("batter_id"): + bio = self._player_bio_cache.get(pbp["batter_id"]) + if bio: + candidate = ("batter", pbp.get("batter_info"), bio) + if candidate is None and show_pitcher and pbp.get("pitcher_id"): + bio = self._player_bio_cache.get(pbp["pitcher_id"]) + if bio: + candidate = ("pitcher", pbp.get("pitcher_info"), bio) + if candidate is None: + return False + + if card_cfg.get("favorites_only", False) and self.favorite_teams: + if ( + game.get("home_abbr") not in self.favorite_teams + and game.get("away_abbr") not in self.favorite_teams + ): + return False + + dwell = card_cfg.get("dwell_seconds", 6) + interval = card_cfg.get("interval_seconds", 40) + + now = time.time() + showing = now < self._player_card_showing_until + if not showing and now - self._player_card_last_shown >= interval: + self._player_card_last_shown = now + self._player_card_showing_until = now + dwell + showing = True + + if not showing: + return False + + role, info, bio = candidate + self._draw_player_card_screen(game, role, info, bio, force_clear) + return True + + @staticmethod + def _format_card_stats(bio: Dict) -> List[Tuple[str, str]]: + """Pick a compact, position-appropriate set of (label, value) stat + pairs from a bio's stats dict. Prefers hitter stats (AVG/HR/RBI); falls + back to pitcher stats (ERA/W-L/K); else surfaces whatever ESPN gave, + so NCAA feeds with sparse/odd labels still show *something*.""" + stats = bio.get("stats") or {} + if not stats: + return [] + + def pick(*names): + for n in names: + for k, v in stats.items(): + if k.upper() == n.upper(): + return v + return None + + pos = (bio.get("position") or "").upper() + is_pitcher = pos in ("P", "SP", "RP", "CP") + + avg, hr, rbi = pick("AVG", "BA"), pick("HR", "homeRuns"), pick("RBI", "RBIs") + era, so = pick("ERA"), pick("SO", "K", "strikeouts") + wins, losses = pick("W", "wins"), pick("L", "losses") + + pairs: List[Tuple[str, str]] = [] + if is_pitcher and (era is not None or wins is not None): + if era is not None: + pairs.append(("ERA", str(era))) + if wins is not None and losses is not None: + pairs.append(("W-L", f"{wins}-{losses}")) + if so is not None: + pairs.append(("K", str(so))) + elif avg is not None or hr is not None or rbi is not None: + if avg is not None: + pairs.append(("AVG", str(avg))) + if hr is not None: + pairs.append(("HR", str(hr))) + if rbi is not None: + pairs.append(("RBI", str(rbi))) + if not pairs: + # Generic fallback: first three labels ESPN provided. + for k, v in list(stats.items())[:3]: + pairs.append((str(k), str(v))) + return pairs + + def _draw_player_card_screen( + self, game: Dict, role: str, info: Optional[Dict], bio: Dict, + force_clear: bool = False, + ) -> None: + """Draw a masters-style player card: headshot (when the panel is big + enough) + name, jersey/position, bat/throw, and season stats. Layout is + tiered by display size; the headshot is hidden on tiny panels and the + text degrades gracefully when fields are missing.""" + try: + w, h = self.display_width, self.display_height + img = Image.new("RGB", (w, h), (0, 0, 0)) + draw = ImageDraw.Draw(img) + + card_cfg = self.config.get("customization", {}).get("player_card", {}) + text_color = tuple(card_cfg.get("text_color", [255, 255, 255])) + stat_color = tuple(card_cfg.get("stat_color", [0, 220, 255])) + border_color = tuple(card_cfg.get("border_color", [255, 200, 0])) + use_team_colors = card_cfg.get("use_team_colors", True) + use_border_color = card_cfg.get("use_team_colors_border", True) + + info = info or {} + name = bio.get("display_name") or info.get("name") or "Player" + jersey = bio.get("jersey") or info.get("jersey") + position = bio.get("position") or info.get("position") or "" + bat = bio.get("bat") or info.get("bat") + throw = bio.get("throw") or info.get("throw") + stats = self._format_card_stats(bio) + + team_color = self._player_card_team_color(game, info) + name_color = team_color if (use_team_colors and team_color) else text_color + frame_color = team_color if (use_border_color and team_color) else border_color + + # Text lines built compactly; label/value stat pairs are rendered + # as "AVG .312 HR 12 RBI 40" style joined strings. + jersey_pos = " ".join( + p for p in (f"#{jersey}" if jersey else "", position) if p + ) + bt = " ".join( + p for p in ((f"B:{bat}" if bat else ""), (f"T:{throw}" if throw else "")) if p + ) + stat_str = " ".join(f"{lbl} {val}" for lbl, val in stats) + + margin = 1 + # Tier by size: hide the headshot on short/narrow panels. + show_headshot = w >= 96 and h >= 32 + headshot = None + headshot_size = 0 + if show_headshot: + # Height-constrained so it never overflows a 32px panel. + headshot_size = min( + max(24, h - 2 * (margin + 2)), h - 2 * (margin + 1), w // 3 + ) + mgr = self._get_headshot_manager() + if mgr is not None: + _, league = self.espn_summary_sport_league + headshot = mgr.load_headshot( + str(bio.get("player_id") or info.get("player_id") or "") + or str(info.get("id") or ""), + bio.get("headshot_url") or info.get("headshot_url"), + league=league, + max_size=headshot_size, + ) + + if headshot is not None: + hx, hy = margin + 1, (h - headshot.height) // 2 + # Team-color / gold frame around the headshot. + draw.rectangle( + [hx - 1, hy - 1, hx + headshot.width, hy + headshot.height], + outline=frame_color, + ) + img.paste(headshot, (hx, hy), headshot) + text_x = hx + headshot.width + 4 + else: + headshot_size = 0 + text_x = margin + 1 + + # Reserve the margin plus 1px for the text outline (which + # _draw_text_with_outline paints 1px beyond the glyph on every + # side) so nothing bleeds into the edge columns. + avail_w = w - text_x - margin - 2 + # Assemble the text lines for this tier. + if not show_headshot and h <= 32: + # Tiny tier: 2 compact centered lines. + lines = [(name if len(name) <= 12 else (info.get("name") or name), name_color)] + bottom = stat_str or jersey_pos + if bottom: + lines.append((bottom, stat_color if stat_str else text_color)) + else: + lines = [(name, name_color)] + meta = " ".join(p for p in (jersey_pos, bt) if p) + if meta: + lines.append((meta, text_color)) + if stat_str: + lines.append((stat_str, stat_color)) + + if not lines: + return + + font_cfg = dict(card_cfg) + font_cfg.setdefault("font", "9x15.bdf") + available_height = h - 2 * margin + font_size_cap = font_cfg.get("font_size", 24) + max_row_h = available_height / max(1, len(lines)) + font_cfg["font_size"] = max(6, min(font_size_cap, round(max_row_h - 3))) + line_texts = [t for t, _ in lines] + font, row_h = self._load_multiline_fit_font( + font_cfg, line_texts, max(8, avail_w), available_height + ) + + total_height = row_h * len(lines) + y = max(margin, (h - total_height) // 2) + for text, color in lines: + if y >= h: + break + text = self._truncate_to_width(draw, text, font, avail_w) + if not show_headshot: + # Center text-only tiers horizontally, keeping >=1px for + # the outline on the left edge. + tw = draw.textbbox((0, 0), text, font=font)[2] + tx = max(margin + 1, (w - tw) // 2) + else: + tx = text_x + self._draw_text_with_outline(draw, text, (tx, y), font, fill=color) + y += row_h + + self.display_manager.image.paste(img, (0, 0)) + self.display_manager.update_display() + + except Exception as e: + self.logger.error(f"Error drawing player card screen: {e}", exc_info=True) + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: """Draw the detailed scorebug layout for a live baseball game.""" if self._maybe_draw_at_bat_info_screen(game, force_clear): return + if self._maybe_draw_player_card_screen(game, force_clear): + return if self._maybe_draw_traditional_scoreboard_screen(game, force_clear): return try: diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index a6058d58..7fc6aa4c 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -257,6 +257,11 @@ "default": false, "description": "Show a short code for the most recently completed play (1B, HR, K, BB, etc.) on the pitcher/batter screen" }, + "show_player_card": { + "type": "boolean", + "default": false, + "description": "Periodically show a full player card for the current batter (and optionally pitcher): headshot, jersey number, position, bat/throw, and season stats (AVG/HR/RBI for hitters, ERA/W-L/K for pitchers). Requires an extra ESPN athlete lookup; not available for MiLB. Configure look/timing under Customization > Player Card" + }, "show_traditional_scoreboard": { "type": "boolean", "default": false, @@ -1032,6 +1037,11 @@ "default": false, "description": "Show a short code for the most recently completed play (1B, HR, K, BB, etc.) on the pitcher/batter screen" }, + "show_player_card": { + "type": "boolean", + "default": false, + "description": "Periodically show a full player card for the current batter (and optionally pitcher): headshot, jersey number, position, bat/throw, and season stats (AVG/HR/RBI for hitters, ERA/W-L/K for pitchers). Requires an extra ESPN athlete lookup; not available for MiLB. Configure look/timing under Customization > Player Card" + }, "show_traditional_scoreboard": { "type": "boolean", "default": false, @@ -1643,6 +1653,92 @@ }, "additionalProperties": false }, + "player_card": { + "type": "object", + "title": "Player Card Screen", + "description": "A dedicated full-screen player card that periodically rotates into the live display for the current batter (and optionally pitcher): headshot image, jersey number, position, bat/throw hand, and season stats (AVG/HR/RBI for hitters, ERA/W-L/K for pitchers). Enable per-league via Display Options > Show Player Card (MLB and NCAA Baseball only -- MiLB has no ESPN player data). On tiny panels (e.g. 64x32) the headshot is hidden and the card shows compact text.", + "properties": { + "font": { + "type": "string", + "default": "9x15.bdf", + "description": "Font for the card's text -- a clean, bold bitmap font that auto-fits the largest size the panel and text allow. A fixed-size .bdf font renders at its native pixel size regardless of font_size; use a scalable .ttf font (e.g. 'press_start') if you want font_size to control the size." + }, + "font_size": { + "type": "integer", + "default": 24, + "minimum": 6, + "maximum": 24, + "description": "Maximum font size cap, for scalable .ttf fonts only (ignored by fixed-size .bdf fonts like the default). The card auto-fits within the space beside the headshot, so the default effectively means 'as big as fits' -- lower it to force a smaller size." + }, + "show_batter": { + "type": "boolean", + "default": true, + "description": "Show a card for the current batter" + }, + "show_pitcher": { + "type": "boolean", + "default": false, + "description": "Also show a card for the current pitcher (the batter is preferred when both are available)" + }, + "use_team_colors": { + "type": "boolean", + "default": true, + "description": "Color the player's name with their real ESPN team color instead of the flat text_color" + }, + "use_team_colors_border": { + "type": "boolean", + "default": true, + "description": "Draw the headshot's frame in the player's team color instead of the flat border_color below" + }, + "border_color": { + "type": "array", + "x-widget": "color-picker", + "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "default": [255, 200, 0], + "minItems": 3, + "maxItems": 3, + "description": "Headshot frame color [R, G, B] when use_team_colors_border is off or the team color is unavailable" + }, + "text_color": { + "type": "array", + "x-widget": "color-picker", + "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "default": [255, 255, 255], + "minItems": 3, + "maxItems": 3, + "description": "Text color [R, G, B] for the name (when team colors are off) and the jersey/position/bat-throw line" + }, + "stat_color": { + "type": "array", + "x-widget": "color-picker", + "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "default": [0, 220, 255], + "minItems": 3, + "maxItems": 3, + "description": "Text color [R, G, B] for the season-stats line" + }, + "dwell_seconds": { + "type": "number", + "default": 6, + "minimum": 2, + "maximum": 20, + "description": "How many seconds to show the card each time it rotates in" + }, + "interval_seconds": { + "type": "number", + "default": 40, + "minimum": 10, + "maximum": 180, + "description": "How often (in seconds) the card rotates into the normal live view" + }, + "favorites_only": { + "type": "boolean", + "default": false, + "description": "Only rotate the card in for games involving one of this league's favorite_teams. Has no effect if favorite_teams is empty." + } + }, + "additionalProperties": false + }, "traditional_scoreboard": { "type": "object", "title": "Traditional Scoreboard Screen", @@ -1665,6 +1761,11 @@ "default": true, "description": "Color each team's abbreviation using their real team colors (from ESPN) instead of a single flat text color" }, + "show_team_color_backgrounds": { + "type": "boolean", + "default": true, + "description": "Tint each team's row with a subtle (~12% brightness) wash of their real ESPN team color, plus a solid team-color accent strip on the left edge -- a colorful ballpark look. Requires use_team_colors; a team with no ESPN color gets no tint. Text stays legible (it is drawn with a black outline over the wash)." + }, "show_logos": { "type": "boolean", "default": true, @@ -1766,6 +1867,7 @@ "outs", "count", "at_bat_info", + "player_card", "traditional_scoreboard" ], "additionalProperties": false diff --git a/plugins/baseball-scoreboard/data_sources.py b/plugins/baseball-scoreboard/data_sources.py index cab0ec2d..8fa8305e 100644 --- a/plugins/baseball-scoreboard/data_sources.py +++ b/plugins/baseball-scoreboard/data_sources.py @@ -156,6 +156,95 @@ def fetch_game_summary(self, sport: str, league: str, event_id: str) -> Optional self.logger.error(f"Error fetching game summary from ESPN for {sport}/{league} event {event_id}: {e}") return None + # ESPN's athlete bio/stats live on a different host than the scoreboard + # (site.web.api vs site.api), under the common/v3 tree. + _ATHLETE_BASE = "https://site.web.api.espn.com/apis/common/v3/sports" + + def fetch_player_details(self, sport: str, league: str, player_id: str) -> Optional[Dict]: + """Fetch a player's bio + season stats from ESPN's athlete + overview + endpoints. Returns a parsed dict (display_name, jersey, position, bat, + throw, height, weight, headshot_url, stats) or None on any failure. + + Mirrors the masters plugin's fetch_player_details; the bio endpoint + carries the identity/headshot and the /overview endpoint carries the + season statistics (which NCAA feeds may omit -- handled gracefully).""" + if not player_id: + return None + try: + bio_url = f"{self._ATHLETE_BASE}/{sport}/{league}/athletes/{player_id}" + bio_resp = self.session.get(bio_url, headers=self.get_headers(), timeout=10) + if bio_resp.status_code != 200: + self.logger.debug( + f"Player bio HTTP {bio_resp.status_code} for {sport}/{league} {player_id}" + ) + return None + bio_data = bio_resp.json() + + overview_data = None + try: + overview_resp = self.session.get( + f"{bio_url}/overview", headers=self.get_headers(), timeout=10 + ) + if overview_resp.status_code == 200: + overview_data = overview_resp.json() + except Exception as e: + self.logger.debug(f"Player overview fetch failed for {player_id}: {e}") + + return self._parse_player_details(bio_data, overview_data) + except Exception as e: + self.logger.debug(f"Failed to fetch player details for {player_id}: {e}") + return None + + @staticmethod + def _parse_player_details(bio_data: Dict, overview_data: Optional[Dict]) -> Optional[Dict]: + """Combine the bio + overview responses into one flat player dict. + + The overview's statistics block is `{names/labels: [...], splits: + [{stats: [...]}, ...]}` -- we zip the labels against the first split's + values into a {label: value} map, then pull the baseball-relevant ones + (AVG/HR/RBI for hitters, ERA/W-L/K for pitchers) while keeping the full + map so anything ESPN provides is available to the renderer.""" + try: + athlete = bio_data.get("athlete") or bio_data + if not isinstance(athlete, dict): + return None + + headshot = (athlete.get("headshot") or {}).get("href") + position = athlete.get("position") or {} + if isinstance(position, dict): + position = position.get("abbreviation") or position.get("displayName") or "" + + stats: Dict[str, str] = {} + if overview_data: + stat_block = overview_data.get("statistics") or {} + labels = stat_block.get("names") or stat_block.get("labels") or [] + splits = stat_block.get("splits") or [] + chosen = splits[0] if isinstance(splits, list) and splits else None + if isinstance(chosen, dict): + values = chosen.get("stats") or [] + for label, value in zip(labels, values): + if label: + stats[str(label)] = value + + return { + "player_id": athlete.get("id"), + "display_name": athlete.get("displayName"), + "first_name": athlete.get("firstName"), + "last_name": athlete.get("lastName"), + "jersey": athlete.get("jersey"), + "position": position or "", + "bat": (athlete.get("bats") or {}).get("abbreviation") + if isinstance(athlete.get("bats"), dict) else athlete.get("bats"), + "throw": (athlete.get("throws") or {}).get("abbreviation") + if isinstance(athlete.get("throws"), dict) else athlete.get("throws"), + "height": athlete.get("displayHeight") or athlete.get("height"), + "weight": athlete.get("displayWeight") or athlete.get("weight"), + "headshot_url": headshot, + "stats": stats, + } + except Exception: + return None + class MLBAPIDataSource(DataSource): """MLB API data source.""" diff --git a/plugins/baseball-scoreboard/logo_manager.py b/plugins/baseball-scoreboard/logo_manager.py index 1a65ad3d..970e27d6 100644 --- a/plugins/baseball-scoreboard/logo_manager.py +++ b/plugins/baseball-scoreboard/logo_manager.py @@ -6,9 +6,11 @@ import os import logging +from io import BytesIO from pathlib import Path from typing import Optional +import requests from PIL import Image # Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 @@ -183,6 +185,69 @@ def load_milb_logo(self, team_abbr: str, logo_dir: Path) -> Optional[Image.Image self.logger.error(f"Error loading MiLB logo for {team_abbr}: {e}", exc_info=True) return None + # Player headshots are cached on disk under the plugin dir, namespaced by + # league (MLB and NCAA athlete-id spaces differ), plus an in-memory cache + # keyed by id+size. Mirrors the masters plugin's headshot loader. + _HEADSHOT_DIR = Path(__file__).resolve().parent / "assets" / "headshots" + + @staticmethod + def _crop_square(img: Image.Image, size: int) -> Image.Image: + """Crop to a square from the top-center (ESPN headshots frame the face + at top-center) and resize to exactly fill a size x size box.""" + w, h = img.size + if w > h: + left = (w - h) // 2 + img = img.crop((left, 0, left + h, h)) + elif h > w: + img = img.crop((0, 0, w, w)) + return img.resize((size, size), RESAMPLE_FILTER) + + def load_headshot( + self, player_id: str, url: Optional[str], league: str = "mlb", max_size: int = 32 + ) -> Optional[Image.Image]: + """Load a player's headshot, crop-to-fill a square, with in-memory + + on-disk caching and download-on-miss. Returns None on any failure so + callers can render a text-only card.""" + if not player_id and not url: + return None + + cache_key = f"headshot_{league}_{player_id}_{max_size}" + if cache_key in self._logo_cache: + return self._logo_cache[cache_key] + + disk_path = None + if player_id: + disk_path = self._HEADSHOT_DIR / league / f"{player_id}.png" + if disk_path.exists(): + try: + with Image.open(disk_path) as src: + img = self._crop_square(src.convert("RGBA"), max_size) + self._logo_cache[cache_key] = img + return img + except Exception as e: + self.logger.debug(f"Failed to load cached headshot {player_id}: {e}") + + if url: + try: + resp = requests.get( + url, timeout=5, headers={"User-Agent": "LEDMatrix Baseball Plugin/1.0"} + ) + resp.raise_for_status() + full = Image.open(BytesIO(resp.content)).convert("RGBA") + if disk_path is not None: + try: + disk_path.parent.mkdir(parents=True, exist_ok=True) + full.save(disk_path, "PNG") + except Exception as e: + self.logger.debug(f"Could not cache headshot to disk for {player_id}: {e}") + img = self._crop_square(full, max_size) + self._logo_cache[cache_key] = img + return img + except Exception as e: + self.logger.debug(f"Failed to download headshot for {player_id}: {e}") + + return None + def clear_cache(self) -> None: """Clear the logo cache.""" self._logo_cache.clear() diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 93c4d1f6..d82873e6 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -691,6 +691,7 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: "show_odds": display_options.get("show_odds", False), "show_pitcher_batter": display_options.get("show_pitcher_batter", False), "show_last_play": display_options.get("show_last_play", False), + "show_player_card": display_options.get("show_player_card", False), "show_traditional_scoreboard": display_options.get("show_traditional_scoreboard", False), "update_interval_seconds": league_config.get( "update_interval_seconds", 300 diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 9288c166..23d42b4f 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.18.0", + "version": "1.19.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -30,6 +30,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-07-09", + "version": "1.19.0", + "notes": "Traditional scoreboard: subtle team-color row tint + left-edge accent strip (toggle show_team_color_backgrounds). Pitcher/Batter screen: labels spelled out ('Pitcher:'/'Batter:') so 'B' is no longer ambiguous. New Player Card screen (show_player_card, MLB & NCAA): rotates in a full card for the current batter/pitcher with headshot, jersey number, position, bat/throw, and season stats (AVG/HR/RBI for hitters, ERA/W-L/K for pitchers); headshot hides on tiny panels, degrades to text-only when unavailable.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-08", "version": "1.18.0", @@ -247,7 +253,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-08", + "last_updated": "2026-07-09", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/baseball-scoreboard/test_player_card.py b/plugins/baseball-scoreboard/test_player_card.py new file mode 100644 index 00000000..d7492965 --- /dev/null +++ b/plugins/baseball-scoreboard/test_player_card.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +""" +Regression tests for the player-card screen in baseball.py. + +Covers: + 1. _build_athlete_info_map: rich roster join (jersey/position/team) across + both teams. + 2. _get_current_pitcher_batter_ids: backward scan returns athlete IDs. + 3. _format_card_stats: hitter vs pitcher stat selection + generic fallback. + 4. _player_card_team_color: resolves from roster team_id vs game home/away. + 5. Gatekeeper: MiLB skip (no espn_summary_sport_league), and skip when no + resolved bio is available (never draws a blank card). + 6. Render smoke test across all supported sizes with a stubbed bio (no + network -> headshot resolves to None -> text-only), asserting no crash, + no margin overflow, and that the headshot is hidden on a 64x32 panel. + +Run: /bin/python plugins/baseball-scoreboard/test_player_card.py +""" + +import os +import sys + +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) +if PLUGIN_DIR not in sys.path: + sys.path.insert(0, PLUGIN_DIR) + +from baseball import ( # noqa: E402 + BaseballLive, + _build_athlete_info_map, + _get_current_pitcher_batter_ids, +) +from data_sources import ESPNDataSource # noqa: E402 + + +class _ConcreteBaseballLive(BaseballLive): + """Minimal concrete BaseballLive so we can instantiate without the full + manager stack (mirrors test_pitcher_batter_last_play.py).""" + + def _extract_game_details(self, game_event): + return None + + def _fetch_data(self): + return None + + +def _play(participants=None): + return {"type": {}, "text": "", "participants": participants or []} + + +def _participant(role, athlete_id): + return {"type": role, "athlete": {"id": athlete_id}} + + +# --- 1. _build_athlete_info_map --------------------------------------------- + +def test_build_athlete_info_map_joins_both_teams_with_fields(): + rosters = [ + { + "team": {"id": "10", "abbreviation": "NYY"}, + "roster": [{"athlete": { + "id": "1", "shortName": "A. Judge", "jersey": "99", + "position": {"abbreviation": "CF"}, "bats": {"abbreviation": "R"}, + "throws": {"abbreviation": "R"}, + "headshot": {"href": "http://x/1.png"}, + }}], + }, + { + "team": {"id": "20", "abbreviation": "BOS"}, + "roster": [{"athlete": { + "id": "2", "shortName": "R. Devers", "jersey": "11", + "position": {"abbreviation": "3B"}, + }}], + }, + ] + info = _build_athlete_info_map(rosters) + assert set(info.keys()) == {"1", "2"}, info + assert info["1"]["jersey"] == "99" and info["1"]["position"] == "CF" + assert info["1"]["team_id"] == "10" and info["1"]["team_abbr"] == "NYY" + assert info["1"]["headshot_url"] == "http://x/1.png" + assert info["1"]["bat"] == "R" and info["1"]["throw"] == "R" + # Missing optional fields degrade to None/"" rather than raising. + assert info["2"]["team_id"] == "20" and info["2"]["headshot_url"] is None + assert info["2"]["bat"] is None + print("test_build_athlete_info_map_joins_both_teams_with_fields: PASS") + + +def test_build_athlete_info_map_handles_empty(): + assert _build_athlete_info_map(None) == {} + assert _build_athlete_info_map([]) == {} + print("test_build_athlete_info_map_handles_empty: PASS") + + +# --- 2. _get_current_pitcher_batter_ids ------------------------------------- + +def test_get_current_pitcher_batter_ids_backward_scan(): + plays = [ + _play(participants=[_participant("pitcher", "10"), _participant("batter", "20")]), + _play(), # trailing transition marker, no participants + ] + pid, bid = _get_current_pitcher_batter_ids(plays) + assert pid == "10" and bid == "20", f"got pitcher_id={pid}, batter_id={bid}" + print("test_get_current_pitcher_batter_ids_backward_scan: PASS") + + +def test_get_current_pitcher_batter_ids_none_when_absent(): + pid, bid = _get_current_pitcher_batter_ids([_play(), _play()]) + assert pid is None and bid is None + print("test_get_current_pitcher_batter_ids_none_when_absent: PASS") + + +# --- 3. _format_card_stats -------------------------------------------------- + +def test_format_card_stats_hitter(): + live = object.__new__(_ConcreteBaseballLive) + bio = {"position": "RF", "stats": {"AVG": ".312", "HR": "12", "RBI": "40", "OBP": ".390"}} + pairs = live._format_card_stats(bio) + labels = [lbl for lbl, _ in pairs] + assert labels == ["AVG", "HR", "RBI"], pairs + assert dict(pairs)["HR"] == "12" + print("test_format_card_stats_hitter: PASS") + + +def test_format_card_stats_pitcher(): + live = object.__new__(_ConcreteBaseballLive) + bio = {"position": "SP", "stats": {"ERA": "2.90", "W": "11", "L": "4", "SO": "180"}} + pairs = live._format_card_stats(bio) + labels = [lbl for lbl, _ in pairs] + assert labels == ["ERA", "W-L", "K"], pairs + assert dict(pairs)["W-L"] == "11-4" + print("test_format_card_stats_pitcher: PASS") + + +def test_format_card_stats_generic_fallback(): + live = object.__new__(_ConcreteBaseballLive) + # NCAA-ish feed with labels we don't specifically map -> first three shown. + bio = {"position": "2B", "stats": {"G": "50", "AB": "180", "H": "55"}} + pairs = live._format_card_stats(bio) + assert len(pairs) == 3 and pairs[0] == ("G", "50"), pairs + # Empty stats -> no pairs (renderer shows name/jersey/pos only). + assert live._format_card_stats({"position": "2B", "stats": {}}) == [] + print("test_format_card_stats_generic_fallback: PASS") + + +# --- 3b. ESPNDataSource._parse_player_details ------------------------------- + +def test_parse_player_details_bio_and_stats(): + bio_data = {"athlete": { + "id": "33", "displayName": "Aaron Judge", "firstName": "Aaron", + "lastName": "Judge", "jersey": "99", + "position": {"abbreviation": "CF"}, + "bats": {"abbreviation": "R"}, "throws": {"abbreviation": "R"}, + "displayHeight": "6' 7\"", "displayWeight": "282 lbs", + "headshot": {"href": "http://x/33.png"}, + }} + overview = {"statistics": { + "names": ["AVG", "HR", "RBI"], + "splits": [{"stats": [".322", "58", "144"]}], + }} + parsed = ESPNDataSource._parse_player_details(bio_data, overview) + assert parsed["display_name"] == "Aaron Judge" + assert parsed["jersey"] == "99" and parsed["position"] == "CF" + assert parsed["bat"] == "R" and parsed["throw"] == "R" + assert parsed["headshot_url"] == "http://x/33.png" + assert parsed["stats"] == {"AVG": ".322", "HR": "58", "RBI": "144"} + print("test_parse_player_details_bio_and_stats: PASS") + + +def test_parse_player_details_tolerates_missing_overview(): + bio_data = {"athlete": {"id": "1", "displayName": "X", "jersey": "1", + "position": {"abbreviation": "SP"}}} + parsed = ESPNDataSource._parse_player_details(bio_data, None) + assert parsed["display_name"] == "X" and parsed["stats"] == {} + assert parsed["headshot_url"] is None + print("test_parse_player_details_tolerates_missing_overview: PASS") + + +def test_parse_player_details_none_on_garbage(): + assert ESPNDataSource._parse_player_details({"athlete": "not-a-dict"}, None) is None + print("test_parse_player_details_none_on_garbage: PASS") + + +# --- 4. _player_card_team_color --------------------------------------------- + +def test_player_card_team_color_from_roster_team_id(): + live = object.__new__(_ConcreteBaseballLive) + game = { + "home_id": "10", "away_id": "20", + "home_team_color": (1, 2, 3), "away_team_color": (4, 5, 6), + } + assert live._player_card_team_color(game, {"team_id": "10"}) == (1, 2, 3) + assert live._player_card_team_color(game, {"team_id": "20"}) == (4, 5, 6) + # Unknown team / missing info -> None (renderer falls back to border_color). + assert live._player_card_team_color(game, {"team_id": "99"}) is None + assert live._player_card_team_color(game, None) is None + print("test_player_card_team_color_from_roster_team_id: PASS") + + +# --- 5. Gatekeeper ---------------------------------------------------------- + +def _make_gate_live(sport_league=("baseball", "mlb"), pbp=None, bio_cache=None, + favorites_only=False, favorite_teams=None): + live = object.__new__(_ConcreteBaseballLive) + live.show_player_card = True + live.espn_summary_sport_league = sport_league + live._play_by_play_cache = {"g1": pbp} if pbp is not None else {} + live._player_bio_cache = bio_cache or {} + live._player_card_last_shown = 0.0 + live._player_card_showing_until = 0.0 + live.favorite_teams = favorite_teams or [] + cfg = {"favorites_only": favorites_only} + live.config = {"customization": {"player_card": cfg}} + drawn = [] + live._draw_player_card_screen = ( + lambda game, role, info, bio, force_clear=False: drawn.append((role, info, bio)) + ) + return live, drawn + + +def test_gate_skips_milb_no_espn_league(): + live, drawn = _make_gate_live( + sport_league=None, + pbp={"batter_id": "b1", "batter_info": {"name": "X"}}, + bio_cache={"b1": {"display_name": "X"}}, + ) + result = live._maybe_draw_player_card_screen({"id": "g1"}) + assert result is False and drawn == [], "expected MiLB (no espn league) to skip the card" + print("test_gate_skips_milb_no_espn_league: PASS") + + +def test_gate_skips_when_no_bio_resolved(): + live, drawn = _make_gate_live( + pbp={"batter_id": "b1", "batter_info": {"name": "X"}}, + bio_cache={}, # no bio fetched yet + ) + result = live._maybe_draw_player_card_screen({"id": "g1"}) + assert result is False and drawn == [], "expected skip (no blank card) when bio is unavailable" + print("test_gate_skips_when_no_bio_resolved: PASS") + + +def test_gate_draws_batter_when_bio_available(): + live, drawn = _make_gate_live( + pbp={"batter_id": "b1", "batter_info": {"name": "Judge"}}, + bio_cache={"b1": {"display_name": "Aaron Judge", "stats": {"AVG": ".322"}}}, + ) + result = live._maybe_draw_player_card_screen({"id": "g1", "home_abbr": "NYY", "away_abbr": "BOS"}) + assert result is True and drawn and drawn[0][0] == "batter", drawn + print("test_gate_draws_batter_when_bio_available: PASS") + + +def test_gate_favorites_only_skips_non_favorite(): + live, drawn = _make_gate_live( + pbp={"batter_id": "b1", "batter_info": {"name": "X"}}, + bio_cache={"b1": {"display_name": "X"}}, + favorites_only=True, favorite_teams=["NYY"], + ) + live._maybe_draw_player_card_screen({"id": "g1", "home_abbr": "BOS", "away_abbr": "TB"}) + assert drawn == [], "expected favorites_only to skip a non-favorite game" + print("test_gate_favorites_only_skips_non_favorite: PASS") + + +# --- 6. Render smoke test --------------------------------------------------- + +class _DisplayManager: + def __init__(self, width, height): + from PIL import Image + self.image = Image.new("RGB", (width, height)) + self._updated = False + + def update_display(self): + self._updated = True + + +def _make_render_live(width, height): + import logging + from PIL import ImageFont + live = object.__new__(_ConcreteBaseballLive) + live.display_width = width + live.display_height = height + live.display_manager = _DisplayManager(width, height) + live.sport_key = "mlb" + live.espn_summary_sport_league = ("baseball", "mlb") + live._headshot_mgr = None + live.config = {} + live.logger = logging.getLogger("test_player_card_render") + # Stub font loading to a default bitmap font so the render path runs even + # off the LEDMatrix core tree (mirrors test_traditional_scoreboard.py). The + # real font metrics are exercised by the safety harness in CI. + _default_font = ImageFont.load_default() + live._load_custom_font_from_element_config = lambda cfg, default_size=6: _default_font + return live + + +_GAME = { + "home_id": "10", "away_id": "20", "home_abbr": "NYY", "away_abbr": "BOS", + "inning_half": "bottom", + "home_team_color": (38, 82, 150), "away_team_color": (189, 48, 57), +} +_INFO = {"name": "A. Judge", "jersey": "99", "position": "CF", + "bat": "R", "throw": "R", "team_id": "10"} +# headshot_url None -> no network; renders text-only. +_BIO = {"display_name": "Aaron Judge", "jersey": "99", "position": "CF", + "bat": "R", "throw": "R", "headshot_url": None, + "stats": {"AVG": ".322", "HR": "58", "RBI": "144"}} + + +def test_render_all_sizes_no_overflow(): + for w, h in ((64, 32), (128, 32), (128, 64), (256, 32)): + live = _make_render_live(w, h) + live._draw_player_card_screen(_GAME, "batter", _INFO, _BIO) + img = live.display_manager.image + margin = 1 + clipped = any( + img.getpixel((x, y)) != (0, 0, 0) + for edge in (range(0, margin), range(w - margin, w)) + for x in edge + for y in range(h) + ) + assert not clipped, f"content drawn into a margin column at {w}x{h}" + lit = any(img.getpixel((x, y)) != (0, 0, 0) for x in range(w) for y in range(h)) + assert lit, f"expected some content drawn at {w}x{h}" + print("test_render_all_sizes_no_overflow: PASS") + + +def test_render_missing_stats_is_text_only_no_crash(): + bio = dict(_BIO) + bio["stats"] = {} # NCAA-ish: no season stats -> name/jersey/pos only + for w, h in ((64, 32), (128, 64)): + live = _make_render_live(w, h) + live._draw_player_card_screen(_GAME, "batter", _INFO, bio) # must not raise + print("test_render_missing_stats_is_text_only_no_crash: PASS") + + +if __name__ == "__main__": + print("player-card tests") + print("=" * 60) + tests = [ + test_build_athlete_info_map_joins_both_teams_with_fields, + test_build_athlete_info_map_handles_empty, + test_get_current_pitcher_batter_ids_backward_scan, + test_get_current_pitcher_batter_ids_none_when_absent, + test_format_card_stats_hitter, + test_format_card_stats_pitcher, + test_format_card_stats_generic_fallback, + test_parse_player_details_bio_and_stats, + test_parse_player_details_tolerates_missing_overview, + test_parse_player_details_none_on_garbage, + test_player_card_team_color_from_roster_team_id, + test_gate_skips_milb_no_espn_league, + test_gate_skips_when_no_bio_resolved, + test_gate_draws_batter_when_bio_available, + test_gate_favorites_only_skips_non_favorite, + test_render_all_sizes_no_overflow, + test_render_missing_stats_is_text_only_no_crash, + ] + failures = 0 + for t in tests: + try: + t() + except AssertionError as e: + failures += 1 + print(f"{t.__name__}: FAIL -- {e}") + except Exception as e: # noqa: BLE001 + failures += 1 + print(f"{t.__name__}: ERROR -- {e}") + print("=" * 60) + print("ALL TESTS PASSED" if failures == 0 else f"{failures} TEST(S) FAILED") + sys.exit(1 if failures else 0) diff --git a/plugins/baseball-scoreboard/test_traditional_scoreboard.py b/plugins/baseball-scoreboard/test_traditional_scoreboard.py index 146668be..a5533484 100644 --- a/plugins/baseball-scoreboard/test_traditional_scoreboard.py +++ b/plugins/baseball-scoreboard/test_traditional_scoreboard.py @@ -210,6 +210,7 @@ def _make_live(width, height): live.display_manager = _DisplayManager(width, height) live.config = {} live.show_traditional_scoreboard = True + live.show_player_card = False live._trad_scoreboard_last_shown = 0.0 live._trad_scoreboard_showing_until = 0.0 live.favorite_teams = [] @@ -336,6 +337,50 @@ def test_team_colors_fall_back_to_text_color_when_disabled(): print("test_team_colors_fall_back_to_text_color_when_disabled: PASS") +_TINT_GAME = { + "away_abbr": "STL", "home_abbr": "MIL", + "away_score": "2", "home_score": "3", + "away_hits": "3", "home_hits": "3", + "away_errors": "0", "home_errors": "0", + "away_linescore": ["0", "0", "2"], "home_linescore": ["2", "0", "1"], + "inning": 4, "inning_half": "top", + "balls": 1, "strikes": 2, "outs": 2, + "is_live": True, "is_final": False, "has_count_data": True, + "away_team_color": (233, 12, 24), "home_team_color": (38, 82, 150), +} +# The row wash is each team color scaled to ~12% brightness (see +# _draw_traditional_scoreboard_screen). int() truncates toward zero. +_AWAY_TINT = (int(233 * 0.12), int(12 * 0.12), int(24 * 0.12)) # (27, 1, 2) +_HOME_TINT = (int(38 * 0.12), int(82 * 0.12), int(150 * 0.12)) # (4, 9, 18) + + +def test_team_color_backgrounds_draw_row_tint_when_enabled(): + live = _make_live(128, 64) + live.config = {"customization": {"traditional_scoreboard": { + "use_team_colors": True, "show_team_color_backgrounds": True, + }}} + live._draw_traditional_scoreboard_screen(dict(_TINT_GAME)) + img = live.display_manager.image + assert _image_contains_color(img, _AWAY_TINT), f"expected away row tint {_AWAY_TINT}" + assert _image_contains_color(img, _HOME_TINT), f"expected home row tint {_HOME_TINT}" + print("test_team_color_backgrounds_draw_row_tint_when_enabled: PASS") + + +def test_team_color_backgrounds_absent_when_disabled(): + live = _make_live(128, 64) + live.config = {"customization": {"traditional_scoreboard": { + "use_team_colors": True, "show_team_color_backgrounds": False, + }}} + live._draw_traditional_scoreboard_screen(dict(_TINT_GAME)) + img = live.display_manager.image + # No wash tint, but the solid team colors (abbreviation text) still appear + # because use_team_colors is on. + assert not _image_contains_color(img, _AWAY_TINT), "row tint should be absent when disabled" + assert not _image_contains_color(img, _HOME_TINT), "row tint should be absent when disabled" + assert _image_contains_color(img, (233, 12, 24)), "solid team color (abbr) should still appear" + print("test_team_color_backgrounds_absent_when_disabled: PASS") + + def test_at_bat_panel_fits_alongside_bigger_font_at_medium_size(): # Regression: an earlier version of the auto-fit sizing math budgeted # exactly enough rows for the grid + At Bat label but rounded away the @@ -725,6 +770,8 @@ def test_draws_without_crashing_when_final_and_no_count_data(): test_draws_without_crashing_at_small_size, test_team_colors_used_when_enabled_and_available, test_team_colors_fall_back_to_text_color_when_disabled, + test_team_color_backgrounds_draw_row_tint_when_enabled, + test_team_color_backgrounds_absent_when_disabled, test_at_bat_panel_fits_alongside_bigger_font_at_medium_size, test_at_bat_column_appears_on_the_right_not_below, test_at_bat_column_at_max_counts_does_not_clip_off_either_edge, From e6c9a7026ad7f67e59c64c47516fd57c5e6fc430 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 23:21:29 +0000 Subject: [PATCH 2/5] fix(baseball-scoreboard): harden headshot cache path + download URL Address a Codacy medium security finding on the new player-card headshot loader: - Sanitize the ESPN-provided player_id and league before using them as filesystem path segments (_safe_filename strips to [A-Za-z0-9_-]) so an unexpected id can't escape the headshot cache directory via '..' or path separators. - Only fetch headshots over http(s), refusing file://, ftp://, etc. Adds a unit test for the filename sanitization. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01R1z28MrGx56x6eiJvibDg9 --- plugins/baseball-scoreboard/logo_manager.py | 19 ++++++++++++++++--- .../baseball-scoreboard/test_player_card.py | 12 ++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/plugins/baseball-scoreboard/logo_manager.py b/plugins/baseball-scoreboard/logo_manager.py index 970e27d6..40e0046f 100644 --- a/plugins/baseball-scoreboard/logo_manager.py +++ b/plugins/baseball-scoreboard/logo_manager.py @@ -190,6 +190,13 @@ def load_milb_logo(self, team_abbr: str, logo_dir: Path) -> Optional[Image.Image # keyed by id+size. Mirrors the masters plugin's headshot loader. _HEADSHOT_DIR = Path(__file__).resolve().parent / "assets" / "headshots" + @staticmethod + def _safe_filename(value: str) -> str: + """Reduce a value (e.g. an ESPN player id) to a safe filename stem -- + only alphanumerics, '_' and '-'. Prevents an unexpected id from + escaping the headshot cache directory via path separators or '..'.""" + return "".join(c for c in str(value or "") if c.isalnum() or c in ("_", "-")) + @staticmethod def _crop_square(img: Image.Image, size: int) -> Image.Image: """Crop to a square from the top-center (ESPN headshots frame the face @@ -215,9 +222,13 @@ def load_headshot( if cache_key in self._logo_cache: return self._logo_cache[cache_key] + # Sanitize the id/league before they touch the filesystem -- they + # originate from ESPN's API, so never trust them as raw path segments. + safe_id = self._safe_filename(player_id) + safe_league = self._safe_filename(league) or "unknown" disk_path = None - if player_id: - disk_path = self._HEADSHOT_DIR / league / f"{player_id}.png" + if safe_id: + disk_path = self._HEADSHOT_DIR / safe_league / f"{safe_id}.png" if disk_path.exists(): try: with Image.open(disk_path) as src: @@ -227,7 +238,9 @@ def load_headshot( except Exception as e: self.logger.debug(f"Failed to load cached headshot {player_id}: {e}") - if url: + # Only fetch over http(s); refuse file://, ftp://, etc. from an + # unexpected URL value. + if url and str(url).lower().startswith(("http://", "https://")): try: resp = requests.get( url, timeout=5, headers={"User-Agent": "LEDMatrix Baseball Plugin/1.0"} diff --git a/plugins/baseball-scoreboard/test_player_card.py b/plugins/baseball-scoreboard/test_player_card.py index d7492965..59733cfc 100644 --- a/plugins/baseball-scoreboard/test_player_card.py +++ b/plugins/baseball-scoreboard/test_player_card.py @@ -179,6 +179,17 @@ def test_parse_player_details_none_on_garbage(): print("test_parse_player_details_none_on_garbage: PASS") +# --- 3c. headshot filename sanitization ------------------------------------- + +def test_safe_filename_strips_path_separators(): + from logo_manager import BaseballLogoManager + assert BaseballLogoManager._safe_filename("33099") == "33099" + assert BaseballLogoManager._safe_filename("../../etc/passwd") == "etcpasswd" + assert BaseballLogoManager._safe_filename("a/b\\c") == "abc" + assert BaseballLogoManager._safe_filename(None) == "" + print("test_safe_filename_strips_path_separators: PASS") + + # --- 4. _player_card_team_color --------------------------------------------- def test_player_card_team_color_from_roster_team_id(): @@ -344,6 +355,7 @@ def test_render_missing_stats_is_text_only_no_crash(): test_parse_player_details_bio_and_stats, test_parse_player_details_tolerates_missing_overview, test_parse_player_details_none_on_garbage, + test_safe_filename_strips_path_separators, test_player_card_team_color_from_roster_team_id, test_gate_skips_milb_no_espn_league, test_gate_skips_when_no_bio_resolved, From a4885015596af3505e9f27059d6dc6d75bf59451 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 00:03:08 +0000 Subject: [PATCH 3/5] fix(baseball-scoreboard): log player-bio cache errors instead of silent pass Resolve the Codacy/Bandit B110 (try/except/pass) medium finding in _fetch_player_bio: the two cache_manager get/set guards swallowed exceptions with a bare `pass`. Log them at debug level instead so cache issues are diagnosable, matching how the rest of the plugin handles non-fatal cache errors. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01R1z28MrGx56x6eiJvibDg9 --- plugins/baseball-scoreboard/baseball.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/baseball-scoreboard/baseball.py b/plugins/baseball-scoreboard/baseball.py index 63a35993..823324be 100644 --- a/plugins/baseball-scoreboard/baseball.py +++ b/plugins/baseball-scoreboard/baseball.py @@ -1222,8 +1222,8 @@ def _fetch_player_bio(self, player_id: str) -> None: if cached is not None: self._player_bio_cache[player_id] = cached or None return - except Exception: - pass + except Exception as e: + self.logger.debug(f"Player bio cache read failed for {player_id}: {e}") import threading import queue @@ -1257,8 +1257,8 @@ def fetch(): self.cache_manager.set( cache_key, result_data or {}, ttl=self.player_bio_update_interval ) - except Exception: - pass + except Exception as e: + self.logger.debug(f"Player bio cache write failed for {player_id}: {e}") # Sample data cycled through in test_mode so the at-bat info screen's # real draw path (font/color/dwell/interval config) gets exercised From ac420ecc5e739d5979497bd281ff8ce6cc16e3d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 00:29:07 +0000 Subject: [PATCH 4/5] fix(baseball-scoreboard): harden + de-block headshot fetch (CodeRabbit review) Address two CodeRabbit review findings on the player-card headshot loader: 1. SSRF + unbounded response (security): the ESPN-supplied headshot URL was fetched with only a scheme check. Now restrict the host to ESPN domains (*.espncdn.com / *.espn.com) via an allowlist, and stream the download with a hard 5 MB size cap (Content-Length check + bounded iter_content) to prevent memory exhaustion. 2. Render-path blocking (stability): load_headshot() previously did a synchronous requests.get on a cache miss straight from _draw_player_card_screen, freezing the display up to 5s when a new batter/pitcher rotated in. The render path is now cache-only (allow_download=False); BaseballLive.update() prefetches headshot bytes off-thread (daemon thread, throttled) to warm the disk cache, mirroring the existing _fetch_player_bio pattern. A cold cache degrades to a text-only card until the prefetch completes. Adds tests for the host allowlist (incl. metadata-IP and look-alike-host rejection) and for the render path never downloading. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01R1z28MrGx56x6eiJvibDg9 --- plugins/baseball-scoreboard/baseball.py | 43 ++++++++++- plugins/baseball-scoreboard/logo_manager.py | 73 ++++++++++++++++--- .../baseball-scoreboard/test_player_card.py | 40 ++++++++++ 3 files changed, 144 insertions(+), 12 deletions(-) diff --git a/plugins/baseball-scoreboard/baseball.py b/plugins/baseball-scoreboard/baseball.py index 823324be..73f33323 100644 --- a/plugins/baseball-scoreboard/baseball.py +++ b/plugins/baseball-scoreboard/baseball.py @@ -1082,6 +1082,7 @@ def __init__( self._player_card_showing_until = 0.0 self._player_bio_cache: Dict[str, Optional[Dict]] = {} self._player_bio_last_attempt: Dict[str, float] = {} + self._headshot_last_attempt: Dict[str, float] = {} self.player_bio_update_interval = self.mode_config.get( "player_bio_update_interval", 300 ) @@ -1114,13 +1115,24 @@ def update(self): # throttled, so we never hammer the athlete API from the render loop. if self.show_player_card: pbp = self._play_by_play_cache.get(game_id) or {} - for pid in (pbp.get("batter_id"), pbp.get("pitcher_id")): + for pid, info in ( + (pbp.get("batter_id"), pbp.get("batter_info")), + (pbp.get("pitcher_id"), pbp.get("pitcher_info")), + ): if not pid: continue last = self._player_bio_last_attempt.get(pid, 0) if now - last >= self.player_bio_update_interval: self._player_bio_last_attempt[pid] = now self._fetch_player_bio(pid) + # Warm the headshot disk cache off-thread so the render path + # (load_headshot with allow_download=False) never blocks on a + # network fetch when a new batter/pitcher rotates in. + bio = self._player_bio_cache.get(pid) or {} + url = bio.get("headshot_url") or (info or {}).get("headshot_url") + if url and now - self._headshot_last_attempt.get(pid, 0) >= self.player_bio_update_interval: + self._headshot_last_attempt[pid] = now + self._prefetch_headshot(pid, url) self._prune_stale_play_by_play() @@ -1260,6 +1272,30 @@ def fetch(): except Exception as e: self.logger.debug(f"Player bio cache write failed for {player_id}: {e}") + def _prefetch_headshot(self, player_id: str, url: str) -> None: + """Warm the on-disk headshot cache in a daemon thread so the render + path only ever does synchronous cache reads (mirrors the non-blocking + background pattern used for play-by-play and bios). Fire-and-forget: + failures are swallowed inside load_headshot's own logging.""" + mgr = self._get_headshot_manager() + if mgr is None or not self.espn_summary_sport_league: + return + _, league = self.espn_summary_sport_league + import threading + + def warm(): + try: + # max_size here only affects the (render-unused) in-memory entry; + # the download writes the full image to disk, which the render + # path then reads and crops at its own tier size. + mgr.load_headshot( + str(player_id), url, league=league, max_size=48, allow_download=True + ) + except Exception as e: + self.logger.debug(f"Headshot prefetch failed for {player_id}: {e}") + + threading.Thread(target=warm, daemon=True).start() + # Sample data cycled through in test_mode so the at-bat info screen's # real draw path (font/color/dwell/interval config) gets exercised # offline, with zero network calls. @@ -1715,12 +1751,17 @@ def _draw_player_card_screen( mgr = self._get_headshot_manager() if mgr is not None: _, league = self.espn_summary_sport_league + # Cache-only on the render path (allow_download=False) so a + # cache miss never blocks the display on a network fetch -- + # update() prefetches the bytes off-thread. A miss simply + # renders text-only until the prefetch warms the cache. headshot = mgr.load_headshot( str(bio.get("player_id") or info.get("player_id") or "") or str(info.get("id") or ""), bio.get("headshot_url") or info.get("headshot_url"), league=league, max_size=headshot_size, + allow_download=False, ) if headshot is not None: diff --git a/plugins/baseball-scoreboard/logo_manager.py b/plugins/baseball-scoreboard/logo_manager.py index 40e0046f..853d229a 100644 --- a/plugins/baseball-scoreboard/logo_manager.py +++ b/plugins/baseball-scoreboard/logo_manager.py @@ -9,6 +9,7 @@ from io import BytesIO from pathlib import Path from typing import Optional +from urllib.parse import urlparse import requests from PIL import Image @@ -189,6 +190,25 @@ def load_milb_logo(self, team_abbr: str, logo_dir: Path) -> Optional[Image.Image # league (MLB and NCAA athlete-id spaces differ), plus an in-memory cache # keyed by id+size. Mirrors the masters plugin's headshot loader. _HEADSHOT_DIR = Path(__file__).resolve().parent / "assets" / "headshots" + # Headshot URLs come from ESPN's athlete API; only fetch from ESPN's own + # domains (SSRF guard) and cap the download size (memory-exhaustion guard). + _ALLOWED_HEADSHOT_HOSTS = (".espncdn.com", ".espn.com") + _MAX_HEADSHOT_BYTES = 5 * 1024 * 1024 # 5 MB -- a headshot PNG is a few KB + + @classmethod + def _is_allowed_headshot_url(cls, url: str) -> bool: + """True only for https(+http) URLs whose host is an ESPN domain.""" + try: + parts = urlparse(url) + except Exception: + return False + if parts.scheme not in ("http", "https"): + return False + host = (parts.hostname or "").lower() + return any( + host == h.lstrip(".") or host.endswith(h) + for h in cls._ALLOWED_HEADSHOT_HOSTS + ) @staticmethod def _safe_filename(value: str) -> str: @@ -210,11 +230,18 @@ def _crop_square(img: Image.Image, size: int) -> Image.Image: return img.resize((size, size), RESAMPLE_FILTER) def load_headshot( - self, player_id: str, url: Optional[str], league: str = "mlb", max_size: int = 32 + self, player_id: str, url: Optional[str], league: str = "mlb", + max_size: int = 32, allow_download: bool = True, ) -> Optional[Image.Image]: """Load a player's headshot, crop-to-fill a square, with in-memory + - on-disk caching and download-on-miss. Returns None on any failure so - callers can render a text-only card.""" + on-disk caching. Returns None on any failure so callers can render a + text-only card. + + allow_download gates the network fetch: the render path passes + allow_download=False so it only ever does fast memory/disk reads (a + cache miss returns None rather than blocking the display), while a + background prefetch passes allow_download=True to warm the disk cache. + """ if not player_id and not url: return None @@ -238,15 +265,13 @@ def load_headshot( except Exception as e: self.logger.debug(f"Failed to load cached headshot {player_id}: {e}") - # Only fetch over http(s); refuse file://, ftp://, etc. from an - # unexpected URL value. - if url and str(url).lower().startswith(("http://", "https://")): + # Network fetch only when explicitly allowed (never on the render path) + # and only from an ESPN host, with a bounded response size. + if allow_download and url and self._is_allowed_headshot_url(url): try: - resp = requests.get( - url, timeout=5, headers={"User-Agent": "LEDMatrix Baseball Plugin/1.0"} - ) - resp.raise_for_status() - full = Image.open(BytesIO(resp.content)).convert("RGBA") + full = self._download_headshot_image(url) + if full is None: + return None if disk_path is not None: try: disk_path.parent.mkdir(parents=True, exist_ok=True) @@ -258,9 +283,35 @@ def load_headshot( return img except Exception as e: self.logger.debug(f"Failed to download headshot for {player_id}: {e}") + elif url and allow_download and not self._is_allowed_headshot_url(url): + self.logger.debug(f"Refusing non-ESPN headshot URL for {player_id}") return None + def _download_headshot_image(self, url: str) -> Optional[Image.Image]: + """Download a headshot with a hard size cap (memory-exhaustion guard), + returning an RGBA image or None. Assumes the URL host is already + allowlisted by the caller.""" + resp = requests.get( + url, timeout=5, stream=True, + headers={"User-Agent": "LEDMatrix Baseball Plugin/1.0"}, + ) + try: + resp.raise_for_status() + declared = resp.headers.get("Content-Length") + if declared is not None and declared.isdigit() and int(declared) > self._MAX_HEADSHOT_BYTES: + self.logger.debug(f"Headshot exceeds size cap (Content-Length={declared})") + return None + content = bytearray() + for chunk in resp.iter_content(8192): + content.extend(chunk) + if len(content) > self._MAX_HEADSHOT_BYTES: + self.logger.debug("Headshot exceeded size cap while streaming") + return None + finally: + resp.close() + return Image.open(BytesIO(bytes(content))).convert("RGBA") + def clear_cache(self) -> None: """Clear the logo cache.""" self._logo_cache.clear() diff --git a/plugins/baseball-scoreboard/test_player_card.py b/plugins/baseball-scoreboard/test_player_card.py index 59733cfc..bfa06f44 100644 --- a/plugins/baseball-scoreboard/test_player_card.py +++ b/plugins/baseball-scoreboard/test_player_card.py @@ -190,6 +190,44 @@ def test_safe_filename_strips_path_separators(): print("test_safe_filename_strips_path_separators: PASS") +def test_headshot_url_allowlist(): + from logo_manager import BaseballLogoManager as B + assert B._is_allowed_headshot_url("https://a.espncdn.com/i/headshots/mlb/players/full/1.png") + assert B._is_allowed_headshot_url("https://secure.espn.com/x.png") + assert not B._is_allowed_headshot_url("https://evil.example.com/x.png") + # SSRF vectors: cloud-metadata IP, non-http scheme, and a look-alike host. + assert not B._is_allowed_headshot_url("http://169.254.169.254/latest/meta-data/") + assert not B._is_allowed_headshot_url("file:///etc/passwd") + assert not B._is_allowed_headshot_url("https://espncdn.com.evil.com/x.png") + print("test_headshot_url_allowlist: PASS") + + +def test_load_headshot_render_path_never_downloads(): + import logging + import logo_manager as lm + mgr = lm.BaseballLogoManager(None, logging.getLogger("t")) + orig_get = lm.requests.get + + def boom(*a, **k): + raise AssertionError("network fetch must not happen here") + + lm.requests.get = boom + try: + # Render path: download disabled -> cache miss returns None, no network. + assert mgr.load_headshot( + "no_such_id_xyz", "https://a.espncdn.com/x.png", + league="mlb", max_size=32, allow_download=False, + ) is None + # Even with download allowed, a non-ESPN host is refused before any GET. + assert mgr.load_headshot( + "no_such_id_xyz2", "https://evil.example.com/x.png", + league="mlb", max_size=32, allow_download=True, + ) is None + finally: + lm.requests.get = orig_get + print("test_load_headshot_render_path_never_downloads: PASS") + + # --- 4. _player_card_team_color --------------------------------------------- def test_player_card_team_color_from_roster_team_id(): @@ -356,6 +394,8 @@ def test_render_missing_stats_is_text_only_no_crash(): test_parse_player_details_tolerates_missing_overview, test_parse_player_details_none_on_garbage, test_safe_filename_strips_path_separators, + test_headshot_url_allowlist, + test_load_headshot_render_path_never_downloads, test_player_card_team_color_from_roster_team_id, test_gate_skips_milb_no_espn_league, test_gate_skips_when_no_bio_resolved, From 2104810af1376bf8a67cd1bc2938ad403a963eaf Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Fri, 10 Jul 2026 14:38:29 -0400 Subject: [PATCH 5/5] fix(baseball-scoreboard): use ledmatrix_min_version in new 1.19.0 entry Same recurring issue as PRs #175/#177/#178: the core's store_manager.py validator flags ledmatrix_min as deprecated and expects ledmatrix_min_version. This PR's new 1.19.0 changelog entry used the deprecated name; corrected to match the non-deprecated field (older entries below 1.6.2 already have this same pre-existing issue but are untouched by this PR, so left as-is -- out of scope here). --- plugins/baseball-scoreboard/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 23d42b4f..016efd5d 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -34,7 +34,7 @@ "released": "2026-07-09", "version": "1.19.0", "notes": "Traditional scoreboard: subtle team-color row tint + left-edge accent strip (toggle show_team_color_backgrounds). Pitcher/Batter screen: labels spelled out ('Pitcher:'/'Batter:') so 'B' is no longer ambiguous. New Player Card screen (show_player_card, MLB & NCAA): rotates in a full card for the current batter/pitcher with headshot, jersey number, position, bat/throw, and season stats (AVG/HR/RBI for hitters, ERA/W-L/K for pitchers); headshot hides on tiny panels, degrades to text-only when unavailable.", - "ledmatrix_min": "2.0.0" + "ledmatrix_min_version": "2.0.0" }, { "released": "2026-07-08",