From ba26001370eb8fa631341c362a13e5ed29b15cf9 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 13 Feb 2026 21:13:31 -0500 Subject: [PATCH 1/6] feat(baseball): add full scorebug rendering with baseball-specific elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate v2.5 baseball scorebug rendering into manager.py with dedicated display methods for live, recent, and upcoming games. Live games now show inning indicator (▲/▼), base diamonds, outs circles, balls-strikes count, and team:score layout. Standardize data extraction to flat dict format matching other sports plugins. Update scroll mode game_renderer.py with matching baseball elements. Remove unused scorebug_renderer.py. Co-Authored-By: Claude Opus 4.6 --- plugins.json | 4 +- plugins/baseball-scoreboard/game_renderer.py | 294 +++++--- plugins/baseball-scoreboard/manager.py | 693 +++++++++++++----- plugins/baseball-scoreboard/manifest.json | 9 +- .../baseball-scoreboard/scorebug_renderer.py | 693 ------------------ plugins/baseball-scoreboard/scroll_display.py | 7 +- 6 files changed, 723 insertions(+), 977 deletions(-) delete mode 100644 plugins/baseball-scoreboard/scorebug_renderer.py diff --git a/plugins.json b/plugins.json index 5f9927e..f8fe62a 100644 --- a/plugins.json +++ b/plugins.json @@ -296,10 +296,10 @@ "plugin_path": "plugins/baseball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-12", + "last_updated": "2026-02-13", "verified": true, "screenshot": "", - "latest_version": "1.0.5" + "latest_version": "1.1.0" }, { "id": "soccer-scoreboard", diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index cbcd6a6..7d54bc2 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -2,7 +2,7 @@ Game Card Renderer for Baseball Scoreboard Plugin Renders individual baseball game cards as PIL Images for use in scroll mode. -Adapted from scorebug_renderer.py but returns images instead of updating display directly. +Returns images instead of updating display directly. """ import logging @@ -145,58 +145,127 @@ def render_game_card(self, game: Dict, game_type: str) -> Image.Image: return self._render_error_card("Unknown type") def _render_live_game(self, game: Dict) -> Image.Image: - """Render a live baseball game card.""" + """Render a live baseball game card with full scorebug elements.""" try: - # Create main image with transparency main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) + draw = ImageDraw.Draw(overlay) - # Get team info and league - home_team = game.get('home_team', {}) - away_team = game.get('away_team', {}) league = game.get('league', 'mlb') - - # Load team logos - home_logo = self._load_and_resize_logo(league, home_team.get('abbrev', '')) - away_logo = self._load_and_resize_logo(league, away_team.get('abbrev', '')) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) if not home_logo or not away_logo: return self._render_error_card("Logo Error") center_y = self.display_height // 2 - # Draw logos - home_x = self.display_width - home_logo.width + 10 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw inning and status - inning_info = game.get('inning_info', {}) - inning = inning_info.get('inning', 1) - inning_half = inning_info.get('half', 'top') - inning_symbol = "▲" if inning_half == 'top' else "▼" - inning_text = f"{inning_symbol}{inning}" - - status_width = draw_overlay.textlength(inning_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 1 - self._draw_text_with_outline(draw_overlay, inning_text, (status_x, status_y), self.fonts['time']) - - # Draw scores - home_score = str(home_team.get("score", "0")) - away_score = str(away_team.get("score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) + # Logos + main_img.paste(home_logo, (self.display_width - home_logo.width + 10, center_y - home_logo.height // 2), home_logo) + main_img.paste(away_logo, (-10, center_y - away_logo.height // 2), away_logo) + + # Inning indicator (top center) + inning_half = game.get('inning_half', 'top') + inning_num = game.get('inning', 1) + if game.get('is_final'): + inning_text = "FINAL" + else: + symbol = "▲" if inning_half == 'top' else "▼" + inning_text = f"{symbol}{inning_num}" + + inning_font = self.fonts['time'] + inning_bbox = draw.textbbox((0, 0), inning_text, font=inning_font) + inning_width = inning_bbox[2] - inning_bbox[0] + inning_x = (self.display_width - inning_width) // 2 + inning_y = 1 + self._draw_text_with_outline(draw, inning_text, (inning_x, inning_y), inning_font) + + # Bases diamond + Outs circles + bases_occupied = game.get('bases_occupied', [False, False, False]) + outs = game.get('outs', 0) + + base_diamond_size = 7 + out_circle_diameter = 3 + out_vertical_spacing = 2 + spacing_between_bases_outs = 3 + base_vert_spacing = 1 + base_horiz_spacing = 1 + + base_cluster_height = base_diamond_size + base_vert_spacing + base_diamond_size + base_cluster_width = base_diamond_size + base_horiz_spacing + base_diamond_size + + overall_start_y = inning_bbox[3] + 1 + bases_origin_x = (self.display_width - base_cluster_width) // 2 + + # Outs column position + out_cluster_height = 3 * out_circle_diameter + 2 * out_vertical_spacing + if inning_half == 'top': + outs_column_x = bases_origin_x - spacing_between_bases_outs - out_circle_diameter + else: + outs_column_x = bases_origin_x + base_cluster_width + spacing_between_bases_outs + outs_column_start_y = overall_start_y + (base_cluster_height // 2) - (out_cluster_height // 2) + + # Draw bases as diamond polygons + h_d = base_diamond_size // 2 + base_fill = (255, 255, 255) + base_outline = (255, 255, 255) + + # 2nd base (top center) + c2x = bases_origin_x + base_cluster_width // 2 + c2y = overall_start_y + h_d + poly2 = [(c2x, overall_start_y), (c2x + h_d, c2y), (c2x, c2y + h_d), (c2x - h_d, c2y)] + draw.polygon(poly2, fill=base_fill if bases_occupied[1] else None, outline=base_outline) + + base_bottom_y = c2y + h_d + + # 3rd base (bottom left) + c3x = bases_origin_x + h_d + c3y = base_bottom_y + base_vert_spacing + h_d + poly3 = [(c3x, base_bottom_y + base_vert_spacing), (c3x + h_d, c3y), (c3x, c3y + h_d), (c3x - h_d, c3y)] + draw.polygon(poly3, fill=base_fill if bases_occupied[2] else None, outline=base_outline) + + # 1st base (bottom right) + c1x = bases_origin_x + base_cluster_width - h_d + c1y = base_bottom_y + base_vert_spacing + h_d + poly1 = [(c1x, base_bottom_y + base_vert_spacing), (c1x + h_d, c1y), (c1x, c1y + h_d), (c1x - h_d, c1y)] + draw.polygon(poly1, fill=base_fill if bases_occupied[0] else None, outline=base_outline) + + # Outs circles + for i in range(3): + cx = outs_column_x + cy = outs_column_start_y + i * (out_circle_diameter + out_vertical_spacing) + coords = [cx, cy, cx + out_circle_diameter, cy + out_circle_diameter] + if i < outs: + draw.ellipse(coords, fill=(255, 255, 255)) + else: + draw.ellipse(coords, outline=(100, 100, 100)) + + # Balls-strikes count (below bases) + balls = game.get('balls', 0) + strikes = game.get('strikes', 0) + count_text = f"{balls}-{strikes}" + count_font = self.fonts['detail'] + count_width = draw.textlength(count_text, font=count_font) + count_y = overall_start_y + base_cluster_height + 2 + count_x = bases_origin_x + (base_cluster_width - count_width) // 2 + self._draw_text_with_outline(draw, count_text, (int(count_x), count_y), count_font) + + # Team:Score at bottom corners + score_font = self.fonts['score'] + away_text = f"{game.get('away_abbr', '')}:{game.get('away_score', '0')}" + home_text = f"{game.get('home_abbr', '')}:{game.get('home_score', '0')}" + try: + font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1] + except AttributeError: + font_height = 8 + score_y = self.display_height - font_height - 2 + self._draw_text_with_outline(draw, away_text, (2, score_y), score_font) + try: + home_w = draw.textbbox((0, 0), home_text, font=score_font)[2] + except AttributeError: + home_w = len(home_text) * 8 + self._draw_text_with_outline(draw, home_text, (self.display_width - home_w - 2, score_y), score_font) - # Composite and convert to RGB main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -207,51 +276,38 @@ def _render_live_game(self, game: Dict) -> Image.Image: def _render_recent_game(self, game: Dict) -> Image.Image: """Render a recent baseball game card.""" try: - # Create main image with transparency main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) + draw = ImageDraw.Draw(overlay) - # Get team info and league - home_team = game.get('home_team', {}) - away_team = game.get('away_team', {}) league = game.get('league', 'mlb') - - # Load team logos - home_logo = self._load_and_resize_logo(league, home_team.get('abbrev', '')) - away_logo = self._load_and_resize_logo(league, away_team.get('abbrev', '')) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) if not home_logo or not away_logo: return self._render_error_card("Logo Error") center_y = self.display_height // 2 - # Draw logos - home_x = self.display_width - home_logo.width + 10 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) + # Logos (tighter fit for recent) + main_img.paste(home_logo, (self.display_width - home_logo.width + 2, center_y - home_logo.height // 2), home_logo) + main_img.paste(away_logo, (-2, center_y - away_logo.height // 2), away_logo) - away_x = -10 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw "Final" status + # "Final" (top center) status_text = "Final" - status_width = draw_overlay.textlength(status_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 1 - self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), self.fonts['time']) - - # Draw scores - home_score = str(home_team.get("score", "0")) - away_score = str(away_team.get("score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) + status_width = draw.textlength(status_text, font=self.fonts['time']) + self._draw_text_with_outline(draw, status_text, ((self.display_width - status_width) // 2, 1), self.fonts['time']) + + # Score (centered) + score_text = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" + score_width = draw.textlength(score_text, font=self.fonts['score']) score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) + score_y = self.display_height - 14 + self._draw_text_with_outline(draw, score_text, (score_x, score_y), self.fonts['score'], fill=(255, 200, 0)) + + # Records at bottom corners + self._draw_records(draw, game) - # Composite and convert to RGB main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -262,49 +318,58 @@ def _render_recent_game(self, game: Dict) -> Image.Image: def _render_upcoming_game(self, game: Dict) -> Image.Image: """Render an upcoming baseball game card.""" try: - # Create main image with transparency main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) + draw = ImageDraw.Draw(overlay) - # Get team info and league - home_team = game.get('home_team', {}) - away_team = game.get('away_team', {}) league = game.get('league', 'mlb') - - # Load team logos - home_logo = self._load_and_resize_logo(league, home_team.get('abbrev', '')) - away_logo = self._load_and_resize_logo(league, away_team.get('abbrev', '')) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) if not home_logo or not away_logo: return self._render_error_card("Logo Error") center_y = self.display_height // 2 - # Draw logos - home_x = self.display_width - home_logo.width + 10 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw game time - game_time = game.get('start_time_short', 'TBD') - time_width = draw_overlay.textlength(game_time, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 - time_y = 1 - self._draw_text_with_outline(draw_overlay, game_time, (time_x, time_y), self.fonts['time']) - - # Draw "vs" in center - vs_text = "VS" - vs_width = draw_overlay.textlength(vs_text, font=self.fonts['score']) - vs_x = (self.display_width - vs_width) // 2 - vs_y = (self.display_height // 2) - 3 - self._draw_text_with_outline(draw_overlay, vs_text, (vs_x, vs_y), self.fonts['score']) - - # Composite and convert to RGB + # Logos (tighter fit) + main_img.paste(home_logo, (self.display_width - home_logo.width + 2, center_y - home_logo.height // 2), home_logo) + main_img.paste(away_logo, (-2, center_y - away_logo.height // 2), away_logo) + + # "Next Game" (top center) + status_font = self.fonts['status'] if self.display_width <= 128 else self.fonts['time'] + status_text = "Next Game" + status_width = draw.textlength(status_text, font=status_font) + self._draw_text_with_outline(draw, status_text, ((self.display_width - status_width) // 2, 1), status_font) + + # Game time/date from start_time + start_time = game.get('start_time', '') + game_date = '' + game_time = '' + if start_time: + try: + from datetime import datetime + import pytz + dt = datetime.fromisoformat(start_time.replace('Z', '+00:00')) + local_tz = pytz.timezone(self.config.get('timezone', 'US/Eastern')) + dt_local = dt.astimezone(local_tz) + game_date = dt_local.strftime('%b %d') + game_time = dt_local.strftime('%-I:%M %p') + except (ValueError, AttributeError, ImportError): + game_time = start_time[:10] if len(start_time) > 10 else start_time + + time_font = self.fonts['time'] + if game_date: + date_width = draw.textlength(game_date, font=time_font) + draw_y = center_y - 7 + self._draw_text_with_outline(draw, game_date, ((self.display_width - date_width) // 2, draw_y), time_font) + if game_time: + time_width = draw.textlength(game_time, font=time_font) + draw_y = center_y + 2 + self._draw_text_with_outline(draw, game_time, ((self.display_width - time_width) // 2, draw_y), time_font) + + # Records at bottom corners + self._draw_records(draw, game) + main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -312,6 +377,25 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: self.logger.exception("Error rendering upcoming game") return self._render_error_card("Display error") + def _draw_records(self, draw, game: Dict): + """Draw team records at bottom corners.""" + away_record = game.get('away_record', '') + home_record = game.get('home_record', '') + if not away_record and not home_record: + return + + record_font = self.fonts['detail'] + record_bbox = draw.textbbox((0, 0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height + + if away_record: + self._draw_text_with_outline(draw, away_record, (0, record_y), record_font) + if home_record: + home_bbox = draw.textbbox((0, 0), home_record, font=record_font) + home_w = home_bbox[2] - home_bbox[0] + self._draw_text_with_outline(draw, home_record, (self.display_width - home_w, record_y), record_font) + def _render_error_card(self, message: str) -> Image.Image: """Render an error message card.""" img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 6be95f6..59e333e 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -349,13 +349,8 @@ def update(self) -> None: def _sort_games(self): """Sort games by priority and favorites.""" def sort_key(game): - league_key = game.get('league') - league_config = game.get('league_config', {}) - status = game.get('status', {}) - # Priority 1: Live games - is_live = status.get('state') == 'in' - live_score = 0 if is_live else 1 + live_score = 0 if game.get('is_live') else 1 # Priority 2: Favorite teams favorite_score = 0 if self._is_favorite_game(game) else 1 @@ -395,31 +390,39 @@ def _fetch_via_data_manager(self, league_key: str, league_config: Dict) -> List[ return self._fetch_league_data_sync(league_key, league_config) def _convert_milb_game(self, milb_data: Dict) -> Dict: - """Convert data_manager MiLB format to the game dict format used by display methods.""" + """Convert data_manager MiLB format to flat game dict format.""" + status_state = milb_data.get('status_state', 'pre') + home_abbr = milb_data.get('home_team', '') + away_abbr = milb_data.get('away_team', '') + logo_dir = self.leagues.get('milb', {}).get('logo_dir', 'assets/sports/milb_logos') return { 'league': 'milb', - 'game_id': milb_data.get('id'), - 'home_team': { - 'name': '', - 'abbrev': milb_data.get('home_team', ''), - 'score': milb_data.get('home_score', 0), - 'logo': None - }, - 'away_team': { - 'name': '', - 'abbrev': milb_data.get('away_team', ''), - 'score': milb_data.get('away_score', 0), - 'logo': None - }, - 'status': { - 'state': milb_data.get('status_state', 'pre'), - 'detail': milb_data.get('detailed_state', ''), - 'short_detail': milb_data.get('detailed_state', ''), - 'period': milb_data.get('inning', 0), - 'display_clock': '' - }, + 'id': milb_data.get('id'), + 'home_abbr': home_abbr, + 'away_abbr': away_abbr, + 'home_id': milb_data.get('home_id', ''), + 'away_id': milb_data.get('away_id', ''), + 'home_score': milb_data.get('home_score', '0'), + 'away_score': milb_data.get('away_score', '0'), + 'home_record': milb_data.get('home_record', ''), + 'away_record': milb_data.get('away_record', ''), + 'home_logo_path': Path(logo_dir) / f"{home_abbr}.png", + 'away_logo_path': Path(logo_dir) / f"{away_abbr}.png", + 'home_logo_url': None, + 'away_logo_url': None, + 'status_state': status_state, + 'status_text': milb_data.get('detailed_state', ''), + 'is_live': status_state == 'in', + 'is_final': status_state == 'post', + 'is_upcoming': status_state == 'pre', 'start_time': milb_data.get('start_time', ''), - 'venue': '' + 'venue': '', + 'inning': milb_data.get('inning', 1), + 'inning_half': milb_data.get('inning_half', 'top'), + 'balls': milb_data.get('balls', 0), + 'strikes': milb_data.get('strikes', 0), + 'outs': milb_data.get('outs', 0), + 'bases_occupied': milb_data.get('bases_occupied', [False, False, False]), } def _fetch_league_data_sync(self, league_key: str, league_config: Dict) -> List[Dict]: @@ -484,11 +487,13 @@ def _process_api_response(self, data: Dict, league_key: str, league_config: Dict return games def _extract_game_info(self, event: Dict, league_key: str, league_config: Dict) -> Optional[Dict]: - """Extract game information from ESPN event.""" + """Extract game information from ESPN event into flat dict format with baseball fields.""" try: competition = event.get('competitions', [{}])[0] status = competition.get('status', {}) competitors = competition.get('competitors', []) + situation = competition.get('situation') + game_date_str = event.get('date', '') if len(competitors) < 2: return None @@ -500,34 +505,122 @@ def _extract_game_info(self, event: Dict, league_key: str, league_config: Dict) if not home_team or not away_team: return None - # Extract game details + try: + home_abbr = home_team['team']['abbreviation'] + except KeyError: + home_abbr = home_team.get('team', {}).get('name', 'UNK')[:3] + try: + away_abbr = away_team['team']['abbreviation'] + except KeyError: + away_abbr = away_team.get('team', {}).get('name', 'UNK')[:3] + + status_state = status.get('type', {}).get('state', 'unknown').lower() + + # Get records + home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else '' + away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else '' + if home_record in {'0-0', '0-0-0'}: + home_record = '' + if away_record in {'0-0', '0-0-0'}: + away_record = '' + + # Determine logo directory based on league + if league_key == 'ncaa_baseball': + logo_dir = 'assets/sports/ncaa_logos' + elif league_key == 'milb': + logo_dir = league_config.get('logo_dir', 'assets/sports/milb_logos') + else: + logo_dir = 'assets/sports/mlb_logos' + game = { 'league': league_key, 'league_config': league_config, - 'game_id': event.get('id'), - 'home_team': { - 'name': home_team.get('team', {}).get('displayName', 'Unknown'), - 'abbrev': home_team.get('team', {}).get('abbreviation', 'UNK'), - 'score': int(home_team.get('score', 0)), - 'logo': home_team.get('team', {}).get('logo') - }, - 'away_team': { - 'name': away_team.get('team', {}).get('displayName', 'Unknown'), - 'abbrev': away_team.get('team', {}).get('abbreviation', 'UNK'), - 'score': int(away_team.get('score', 0)), - 'logo': away_team.get('team', {}).get('logo') - }, - 'status': { - 'state': status.get('type', {}).get('state', 'unknown'), - 'detail': status.get('type', {}).get('detail', ''), - 'short_detail': status.get('type', {}).get('shortDetail', ''), - 'period': status.get('period', 0), - 'display_clock': status.get('displayClock', '') - }, - 'start_time': event.get('date', ''), - 'venue': competition.get('venue', {}).get('fullName', 'Unknown Venue') + 'id': event.get('id'), + 'home_abbr': home_abbr, + 'away_abbr': away_abbr, + 'home_id': home_team.get('id'), + 'away_id': away_team.get('id'), + 'home_score': home_team.get('score', '0'), + 'away_score': away_team.get('score', '0'), + 'home_record': home_record, + 'away_record': away_record, + 'home_logo_path': Path(logo_dir) / f"{home_abbr}.png", + 'away_logo_path': Path(logo_dir) / f"{away_abbr}.png", + 'home_logo_url': home_team.get('team', {}).get('logo'), + 'away_logo_url': away_team.get('team', {}).get('logo'), + 'status_state': status_state, + 'status_text': status.get('type', {}).get('shortDetail', ''), + 'is_live': status_state == 'in', + 'is_final': status_state == 'post', + 'is_upcoming': status_state == 'pre', + 'start_time': game_date_str, + 'venue': competition.get('venue', {}).get('fullName', ''), } + # Extract baseball-specific details for live games + if status_state == 'in': + inning = status.get('period', 1) + status_detail = status.get('type', {}).get('detail', '').lower() + status_short = status.get('type', {}).get('shortDetail', '').lower() + + # Determine inning half from status text + inning_half = 'top' + if 'end' in status_detail or 'end' in status_short: + inning_half = 'top' + inning = status.get('period', 1) + 1 + elif 'mid' in status_detail or 'mid' in status_short: + inning_half = 'bottom' + elif 'bottom' in status_detail or 'bot' in status_detail or 'bottom' in status_short or 'bot' in status_short: + inning_half = 'bottom' + elif 'top' in status_detail or 'top' in status_short: + inning_half = 'top' + + # Get count and bases from situation + count = situation.get('count', {}) if situation else {} + balls = count.get('balls', 0) + strikes = count.get('strikes', 0) + outs = situation.get('outs', 0) if situation else 0 + + # Try alternative locations for count data + if balls == 0 and strikes == 0 and situation: + if 'summary' in situation: + try: + balls, strikes = map(int, situation['summary'].split('-')) + except (ValueError, AttributeError): + pass + else: + balls = situation.get('balls', 0) + strikes = situation.get('strikes', 0) + + bases_occupied = [ + situation.get('onFirst', False) if situation else False, + situation.get('onSecond', False) if situation else False, + situation.get('onThird', False) if situation else False, + ] + + game.update({ + 'inning': inning, + 'inning_half': inning_half, + 'balls': balls, + 'strikes': strikes, + 'outs': outs, + 'bases_occupied': bases_occupied, + }) + else: + game.update({ + 'inning': 1, + 'inning_half': 'top', + 'balls': 0, + 'strikes': 0, + 'outs': 0, + 'bases_occupied': [False, False, False], + }) + + # Get series summary if available + series = competition.get('series') + if series: + game['series_summary'] = series.get('summary', '') + return game except Exception as e: @@ -536,17 +629,13 @@ def _extract_game_info(self, event: Dict, league_key: str, league_config: Dict) def _is_favorite_game(self, game: Dict) -> bool: """Check if game involves a favorite team.""" - league = game.get('league') league_config = game.get('league_config', {}) favorites = league_config.get('favorite_teams', []) if not favorites: return False - home_abbrev = game.get('home_team', {}).get('abbrev') - away_abbrev = game.get('away_team', {}).get('abbrev') - - return home_abbrev in favorites or away_abbrev in favorites + return game.get('home_abbr', '') in favorites or game.get('away_abbr', '') in favorites def display(self, display_mode: str = None, force_clear: bool = False) -> bool: """ @@ -601,8 +690,7 @@ def _filter_games_by_mode(self, mode: str) -> List[Dict]: for game in games_copy: league_key = game.get('league') league_config = game.get('league_config', {}) - status = game.get('status', {}) - state = status.get('state') + status_state = game.get('status_state', '') # Check if this mode is enabled for this league display_modes = league_config.get('display_modes', {}) @@ -616,21 +704,19 @@ def _filter_games_by_mode(self, mode: str) -> List[Dict]: continue # Filter by game state and per-league limits - if mode == 'baseball_live' and state == 'in': + if mode == 'baseball_live' and status_state == 'in': filtered.append(game) - elif mode == 'baseball_recent' and state == 'post': - # Check recent games limit for this league + elif mode == 'baseball_recent' and status_state == 'post': recent_limit = league_config.get('recent_games_to_show', 5) - recent_count = len([g for g in filtered if g.get('league') == league_key and g.get('status', {}).get('state') == 'post']) + recent_count = len([g for g in filtered if g.get('league') == league_key and g.get('is_final')]) if recent_count >= recent_limit: continue filtered.append(game) - elif mode == 'baseball_upcoming' and state == 'pre': - # Check upcoming games limit for this league + elif mode == 'baseball_upcoming' and status_state == 'pre': upcoming_limit = league_config.get('upcoming_games_to_show', 10) - upcoming_count = len([g for g in filtered if g.get('league') == league_key and g.get('status', {}).get('state') == 'pre']) + upcoming_count = len([g for g in filtered if g.get('league') == league_key and g.get('is_upcoming')]) if upcoming_count >= upcoming_limit: continue filtered.append(game) @@ -640,12 +726,12 @@ def _filter_games_by_mode(self, mode: str) -> List[Dict]: def _has_live_games(self) -> bool: """Check if there are any live games available.""" with self._games_lock: - return any(game.get('status', {}).get('state') == 'in' for game in self.current_games) + return any(game.get('is_live') for game in self.current_games) def _has_recent_games(self) -> bool: """Check if there are any recent games available.""" with self._games_lock: - return any(game.get('status', {}).get('state') == 'post' for game in self.current_games) + return any(game.get('is_final') for game in self.current_games) def has_live_content(self) -> bool: """ @@ -661,13 +747,16 @@ def get_live_modes(self) -> list: """ return ['baseball_live'] - def _load_team_logo(self, team: Dict, league: str) -> Optional[Image.Image]: - """Load and resize team logo - matching football plugin logic.""" + def _load_team_logo(self, team_abbrev: str, league: str) -> Optional[Image.Image]: + """Load and resize team logo.""" try: + if not team_abbrev: + return None + # Get logo directory from league configuration league_config = self.leagues.get(league, {}) logo_dir = league_config.get('logo_dir', 'assets/sports/mlb_logos') - + # Convert relative path to absolute path by finding LEDMatrix project root if not os.path.isabs(logo_dir): current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -676,21 +765,17 @@ def _load_team_logo(self, team: Dict, league: str) -> Optional[Image.Image]: if os.path.exists(os.path.join(parent, 'assets', 'sports')): ledmatrix_root = parent break - + if ledmatrix_root: logo_dir = os.path.join(ledmatrix_root, logo_dir) else: logo_dir = os.path.abspath(logo_dir) - - team_abbrev = team.get('abbrev', '') - if not team_abbrev: - return None - + # Try different case variations and extensions logo_extensions = ['.png', '.jpg', '.jpeg'] logo_path = None abbrev_variations = [team_abbrev.upper(), team_abbrev.lower(), team_abbrev] - + for abbrev in abbrev_variations: for ext in logo_extensions: potential_path = os.path.join(logo_dir, f"{abbrev}{ext}") @@ -699,20 +784,20 @@ def _load_team_logo(self, team: Dict, league: str) -> Optional[Image.Image]: break if logo_path: break - + if not logo_path: return None - + # Load and resize logo (matching original managers) logo = Image.open(logo_path).convert('RGBA') max_width = int(self.display_manager.matrix.width * 1.5) max_height = int(self.display_manager.matrix.height * 1.5) logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - + return logo - + except Exception as e: - self.logger.debug(f"Could not load logo for {team.get('abbrev', 'unknown')}: {e}") + self.logger.debug(f"Could not load logo for {team_abbrev}: {e}") return None def _draw_text_with_outline(self, draw: ImageDraw.Draw, text: str, position: tuple, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): @@ -728,83 +813,353 @@ def _draw_text_with_outline(self, draw: ImageDraw.Draw, text: str, position: tup self.logger.error(f"Error drawing text with outline: {e}") def _display_game(self, game: Dict, mode: str): - """Display a single baseball game with proper scoreboard layout.""" + """Display a single baseball game, routing to the appropriate renderer.""" try: - matrix_width = self.display_manager.matrix.width - matrix_height = self.display_manager.matrix.height - - # Create image with transparency support - main_img = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) - - # Get team info - home_team = game.get('home_team', {}) - away_team = game.get('away_team', {}) - status = game.get('status', {}) - - # Load team logos - home_logo = self._load_team_logo(home_team, game.get('league', '')) - away_logo = self._load_team_logo(away_team, game.get('league', '')) - - if home_logo and away_logo: - # Draw logos with layout offset support - center_y = matrix_height // 2 - home_x = matrix_width - home_logo.width + 10 + self._get_layout_offset('home_logo', 'x_offset') - home_y = center_y - (home_logo.height // 2) + self._get_layout_offset('home_logo', 'y_offset') - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 + self._get_layout_offset('away_logo', 'x_offset') - away_y = center_y - (away_logo.height // 2) + self._get_layout_offset('away_logo', 'y_offset') - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw scores (centered) with layout offset support - home_score = str(home_team.get('score', 0)) - away_score = str(away_team.get('score', 0)) - score_text = f"{away_score}-{home_score}" - - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (matrix_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') - score_y = (matrix_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score'], fill=(255, 200, 0)) - - # Inning/Status (top center) with layout offset support - if status.get('state') == 'post': - status_text = "FINAL" - elif status.get('state') == 'pre': - status_text = "UPCOMING" - else: - # Live game - show inning - status_text = status.get('detail', status.get('short_detail', '')) - - status_width = draw_overlay.textlength(status_text, font=self.fonts['time']) - status_x = (matrix_width - status_width) // 2 + self._get_layout_offset('status', 'x_offset') - status_y = 1 + self._get_layout_offset('status', 'y_offset') - self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), self.fonts['time'], fill=(0, 255, 0)) - - # Composite and display - final_img = Image.alpha_composite(main_img, overlay) - self.display_manager.image = final_img.convert('RGB').copy() + if mode == 'baseball_live': + self._display_live_game(game) + elif mode == 'baseball_recent': + self._display_recent_game(game) + elif mode == 'baseball_upcoming': + self._display_upcoming_game(game) else: - # Text fallback if logos fail - img = Image.new('RGB', (matrix_width, matrix_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - - home_abbrev = home_team.get('abbrev', 'HOME') - away_abbrev = away_team.get('abbrev', 'AWAY') - - draw.text((5, 5), f"{away_abbrev} @ {home_abbrev}", fill=(255, 255, 255)) - draw.text((5, 15), f"{away_team.get('score', 0)} - {home_team.get('score', 0)}", fill=(255, 200, 0)) - draw.text((5, 25), status.get('short_detail', ''), fill=(0, 255, 0)) - - self.display_manager.image = img.copy() - - self.display_manager.update_display() - + self._display_recent_game(game) except Exception as e: - self.logger.error(f"Error displaying game: {e}") + self.logger.error(f"Error displaying game: {e}", exc_info=True) self._display_error("Display error") + def _paste_logos(self, main_img: Image.Image, game: Dict, inward_offset: int = 10): + """Load and paste team logos onto the image. Returns (home_logo, away_logo) or (None, None).""" + league = game.get('league', '') + home_logo = self._load_team_logo(game.get('home_abbr', ''), league) + away_logo = self._load_team_logo(game.get('away_abbr', ''), league) + + if not home_logo or not away_logo: + return None, None + + center_y = main_img.height // 2 + home_x = main_img.width - home_logo.width + inward_offset + self._get_layout_offset('home_logo', 'x_offset') + home_y = center_y - (home_logo.height // 2) + self._get_layout_offset('home_logo', 'y_offset') + main_img.paste(home_logo, (home_x, home_y), home_logo) + + away_x = -inward_offset + self._get_layout_offset('away_logo', 'x_offset') + away_y = center_y - (away_logo.height // 2) + self._get_layout_offset('away_logo', 'y_offset') + main_img.paste(away_logo, (away_x, away_y), away_logo) + + return home_logo, away_logo + + def _draw_records(self, draw: ImageDraw.Draw, game: Dict, width: int, height: int): + """Draw team records or rankings at bottom corners if enabled.""" + league_config = game.get('league_config', {}) + show_records = league_config.get('show_records', self.show_records) + show_ranking = league_config.get('show_ranking', self.show_ranking) + + if not show_records and not show_ranking: + return + + record_font = self.fonts['detail'] + record_bbox = draw.textbbox((0, 0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = height - record_height + + # Away team (bottom left) + away_text = '' + if show_ranking: + away_text = game.get('away_record', '') # rankings would replace this if available + elif show_records: + away_text = game.get('away_record', '') + if away_text: + self._draw_text_with_outline(draw, away_text, (0, record_y), record_font) + + # Home team (bottom right) + home_text = '' + if show_ranking: + home_text = game.get('home_record', '') + elif show_records: + home_text = game.get('home_record', '') + if home_text: + home_bbox = draw.textbbox((0, 0), home_text, font=record_font) + home_w = home_bbox[2] - home_bbox[0] + self._draw_text_with_outline(draw, home_text, (width - home_w, record_y), record_font) + + def _display_live_game(self, game: Dict): + """Display a live baseball game with full scorebug: bases, outs, count, inning.""" + matrix_width = self.display_manager.matrix.width + matrix_height = self.display_manager.matrix.height + + main_img = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 255)) + overlay = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # Logos + home_logo, away_logo = self._paste_logos(main_img, game, inward_offset=10) + if not home_logo or not away_logo: + self._display_text_fallback(game) + return + + # --- Inning indicator (top center) --- + inning_half = game.get('inning_half', 'top') + inning_num = game.get('inning', 1) + if game.get('is_final'): + inning_text = "FINAL" + else: + symbol = "▲" if inning_half == 'top' else "▼" + inning_text = f"{symbol}{inning_num}" + + inning_font = self.fonts['time'] + inning_bbox = draw.textbbox((0, 0), inning_text, font=inning_font) + inning_width = inning_bbox[2] - inning_bbox[0] + inning_x = (matrix_width - inning_width) // 2 + self._get_layout_offset('status', 'x_offset') + inning_y = 1 + self._get_layout_offset('status', 'y_offset') + self._draw_text_with_outline(draw, inning_text, (inning_x, inning_y), inning_font) + + # --- Bases diamond + Outs circles --- + bases_occupied = game.get('bases_occupied', [False, False, False]) + outs = game.get('outs', 0) + + # Geometry constants (from v2.5) + base_diamond_size = 7 + out_circle_diameter = 3 + out_vertical_spacing = 2 + spacing_between_bases_outs = 3 + base_vert_spacing = 1 + base_horiz_spacing = 1 + + base_cluster_height = base_diamond_size + base_vert_spacing + base_diamond_size + base_cluster_width = base_diamond_size + base_horiz_spacing + base_diamond_size + out_cluster_height = 3 * out_circle_diameter + 2 * out_vertical_spacing + + overall_start_y = inning_bbox[3] + 1 # just below inning text + + # Center bases horizontally + bases_origin_x = (matrix_width - base_cluster_width) // 2 + + # Outs column position depends on inning half + if inning_half == 'top': + outs_column_x = bases_origin_x - spacing_between_bases_outs - out_circle_diameter + else: + outs_column_x = bases_origin_x + base_cluster_width + spacing_between_bases_outs + + outs_column_start_y = overall_start_y + (base_cluster_height // 2) - (out_cluster_height // 2) + + # Draw bases as diamond polygons + base_color_filled = (255, 255, 255) + base_color_outline = (255, 255, 255) + h_d = base_diamond_size // 2 + + # 2nd base (top center) + c2x = bases_origin_x + base_cluster_width // 2 + c2y = overall_start_y + h_d + poly2 = [(c2x, overall_start_y), (c2x + h_d, c2y), (c2x, c2y + h_d), (c2x - h_d, c2y)] + if bases_occupied[1]: + draw.polygon(poly2, fill=base_color_filled) + else: + draw.polygon(poly2, outline=base_color_outline) + + base_bottom_y = c2y + h_d + + # 3rd base (bottom left) + c3x = bases_origin_x + h_d + c3y = base_bottom_y + base_vert_spacing + h_d + poly3 = [(c3x, base_bottom_y + base_vert_spacing), (c3x + h_d, c3y), (c3x, c3y + h_d), (c3x - h_d, c3y)] + if bases_occupied[2]: + draw.polygon(poly3, fill=base_color_filled) + else: + draw.polygon(poly3, outline=base_color_outline) + + # 1st base (bottom right) + c1x = bases_origin_x + base_cluster_width - h_d + c1y = base_bottom_y + base_vert_spacing + h_d + poly1 = [(c1x, base_bottom_y + base_vert_spacing), (c1x + h_d, c1y), (c1x, c1y + h_d), (c1x - h_d, c1y)] + if bases_occupied[0]: + draw.polygon(poly1, fill=base_color_filled) + else: + draw.polygon(poly1, outline=base_color_outline) + + # Draw outs (3 vertical circles) + circle_color_filled = (255, 255, 255) + circle_color_empty = (100, 100, 100) + + for i in range(3): + cx = outs_column_x + cy = outs_column_start_y + i * (out_circle_diameter + out_vertical_spacing) + coords = [cx, cy, cx + out_circle_diameter, cy + out_circle_diameter] + if i < outs: + draw.ellipse(coords, fill=circle_color_filled) + else: + draw.ellipse(coords, outline=circle_color_empty) + + # --- Balls-strikes count (below bases) --- + balls = game.get('balls', 0) + strikes = game.get('strikes', 0) + count_text = f"{balls}-{strikes}" + count_font = self.fonts['detail'] + count_width = draw.textlength(count_text, font=count_font) + cluster_bottom_y = overall_start_y + base_cluster_height + count_y = cluster_bottom_y + 2 + count_x = bases_origin_x + (base_cluster_width - count_width) // 2 + self._draw_text_with_outline(draw, count_text, (int(count_x), count_y), count_font) + + # --- Team:Score at bottom corners --- + score_font = self.fonts['score'] + away_abbr = game.get('away_abbr', '') + home_abbr = game.get('home_abbr', '') + away_score_str = str(game.get('away_score', '0')) + home_score_str = str(game.get('home_score', '0')) + away_text = f"{away_abbr}:{away_score_str}" + home_text = f"{home_abbr}:{home_score_str}" + + try: + font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1] + except AttributeError: + font_height = 8 + score_y = matrix_height - font_height - 2 + + # Away (bottom left) + self._draw_text_with_outline(draw, away_text, (2, score_y), score_font) + + # Home (bottom right) + try: + home_text_width = draw.textbbox((0, 0), home_text, font=score_font)[2] + except AttributeError: + home_text_width = len(home_text) * 8 + self._draw_text_with_outline(draw, home_text, (matrix_width - home_text_width - 2, score_y), score_font) + + # Composite and display + final_img = Image.alpha_composite(main_img, overlay) + self.display_manager.image = final_img.convert('RGB').copy() + self.display_manager.update_display() + + def _display_recent_game(self, game: Dict): + """Display a recent (final) baseball game.""" + matrix_width = self.display_manager.matrix.width + matrix_height = self.display_manager.matrix.height + + main_img = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 255)) + overlay = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # Logos (tighter fit for recent/upcoming) + home_logo, away_logo = self._paste_logos(main_img, game, inward_offset=2) + if not home_logo or not away_logo: + self._display_text_fallback(game) + return + + # "Final" text (top center) + status_text = "Final" + status_font = self.fonts['time'] + status_width = draw.textlength(status_text, font=status_font) + status_x = (matrix_width - status_width) // 2 + self._get_layout_offset('status', 'x_offset') + status_y = 1 + self._get_layout_offset('status', 'y_offset') + self._draw_text_with_outline(draw, status_text, (status_x, status_y), status_font) + + # Score (centered) + away_score = str(game.get('away_score', '0')) + home_score = str(game.get('home_score', '0')) + score_text = f"{away_score}-{home_score}" + score_font = self.fonts['score'] + score_width = draw.textlength(score_text, font=score_font) + score_x = (matrix_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') + score_y = matrix_height - 14 + self._get_layout_offset('score', 'y_offset') + self._draw_text_with_outline(draw, score_text, (score_x, score_y), score_font, fill=(255, 200, 0)) + + # Records at bottom corners + self._draw_records(draw, game, matrix_width, matrix_height) + + # Series summary (centered, if available) + series_summary = game.get('series_summary', '') + if series_summary: + series_font = self.fonts['time'] + series_width = draw.textlength(series_summary, font=series_font) + series_bbox = draw.textbbox((0, 0), series_summary, font=series_font) + series_height = series_bbox[3] - series_bbox[1] + series_x = (matrix_width - series_width) // 2 + series_y = (matrix_height - series_height) // 2 + self._draw_text_with_outline(draw, series_summary, (series_x, series_y), series_font) + + # Composite and display + final_img = Image.alpha_composite(main_img, overlay) + self.display_manager.image = final_img.convert('RGB').copy() + self.display_manager.update_display() + + def _display_upcoming_game(self, game: Dict): + """Display an upcoming baseball game.""" + matrix_width = self.display_manager.matrix.width + matrix_height = self.display_manager.matrix.height + + main_img = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 255)) + overlay = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # Logos (tighter fit) + home_logo, away_logo = self._paste_logos(main_img, game, inward_offset=2) + if not home_logo or not away_logo: + self._display_text_fallback(game) + return + + # "Next Game" (top center) + status_font = self.fonts['status'] if matrix_width <= 128 else self.fonts['time'] + status_text = "Next Game" + status_width = draw.textlength(status_text, font=status_font) + status_x = (matrix_width - status_width) // 2 + self._get_layout_offset('status', 'x_offset') + status_y = 1 + self._get_layout_offset('status', 'y_offset') + self._draw_text_with_outline(draw, status_text, (status_x, status_y), status_font) + + # Parse start time for date and time display + center_y = matrix_height // 2 + game_date = '' + game_time = '' + start_time = game.get('start_time', '') + if start_time: + try: + dt = datetime.fromisoformat(start_time.replace('Z', '+00:00')) + local_tz = pytz.timezone(self.config.get('timezone', 'US/Eastern')) + dt_local = dt.astimezone(local_tz) + game_date = dt_local.strftime('%b %d') + game_time = dt_local.strftime('%-I:%M %p') + except (ValueError, AttributeError): + game_date = '' + game_time = start_time[:10] if len(start_time) > 10 else start_time + + # Date (centered) + time_font = self.fonts['time'] + if game_date: + date_width = draw.textlength(game_date, font=time_font) + date_x = (matrix_width - date_width) // 2 + date_y = center_y - 7 + self._draw_text_with_outline(draw, game_date, (date_x, date_y), time_font) + + # Time (centered, below date) + if game_time: + time_width = draw.textlength(game_time, font=time_font) + time_x = (matrix_width - time_width) // 2 + time_y = center_y + 2 + self._draw_text_with_outline(draw, game_time, (time_x, time_y), time_font) + + # Records at bottom corners + self._draw_records(draw, game, matrix_width, matrix_height) + + # Composite and display + final_img = Image.alpha_composite(main_img, overlay) + self.display_manager.image = final_img.convert('RGB').copy() + self.display_manager.update_display() + + def _display_text_fallback(self, game: Dict): + """Text-only fallback when logos fail to load.""" + matrix_width = self.display_manager.matrix.width + matrix_height = self.display_manager.matrix.height + img = Image.new('RGB', (matrix_width, matrix_height), (0, 0, 0)) + draw = ImageDraw.Draw(img) + + away_abbr = game.get('away_abbr', 'AWAY') + home_abbr = game.get('home_abbr', 'HOME') + + draw.text((5, 5), f"{away_abbr} @ {home_abbr}", fill=(255, 255, 255)) + draw.text((5, 15), f"{game.get('away_score', 0)} - {game.get('home_score', 0)}", fill=(255, 200, 0)) + draw.text((5, 25), game.get('status_text', ''), fill=(0, 255, 0)) + + self.display_manager.image = img.copy() + self.display_manager.update_display() + def _display_no_games(self, mode: str): """Display message when no games are available.""" img = Image.new('RGB', (self.display_manager.matrix.width, @@ -857,9 +1212,9 @@ def get_info(self) -> Dict[str, Any]: # Access current_games under lock for thread safety with self._games_lock: total_games = len(self.current_games) - live_games = len([g for g in self.current_games if g.get('status', {}).get('state') == 'in']) - recent_games = len([g for g in self.current_games if g.get('status', {}).get('state') == 'post']) - upcoming_games = len([g for g in self.current_games if g.get('status', {}).get('state') == 'pre']) + live_games = len([g for g in self.current_games if g.get('is_live')]) + recent_games = len([g for g in self.current_games if g.get('is_final')]) + upcoming_games = len([g for g in self.current_games if g.get('is_upcoming')]) info.update({ 'total_games': total_games, @@ -919,12 +1274,11 @@ def _collect_games_for_scroll(self) -> tuple: continue # Determine game type from state - state = game.get('status', {}).get('state', '') - if state == 'in': + if game.get('is_live'): game_type = 'live' - elif state == 'post': + elif game.get('is_final'): game_type = 'recent' - elif state == 'pre': + elif game.get('is_upcoming'): game_type = 'upcoming' else: continue @@ -946,11 +1300,9 @@ def _collect_games_for_scroll(self) -> tuple: for league in leagues: # Sort games within league: live first, then recent, then upcoming league_games = games_by_league[league] - league_games.sort(key=lambda g: { - 'in': 0, # live first - 'post': 1, # recent second - 'pre': 2 # upcoming third - }.get(g.get('status', {}).get('state', ''), 3)) + league_games.sort(key=lambda g: ( + 0 if g.get('is_live') else 1 if g.get('is_final') else 2 + )) all_games.extend(league_games) @@ -1031,12 +1383,11 @@ def _ensure_scroll_content_for_vegas(self) -> None: # Count games by type for logging game_type_counts = {'live': 0, 'recent': 0, 'upcoming': 0} for game in games: - state = game.get('status', {}).get('state', '') - if state == 'in': + if game.get('is_live'): game_type_counts['live'] += 1 - elif state == 'post': + elif game.get('is_final'): game_type_counts['recent'] += 1 - elif state == 'pre': + elif game.get('is_upcoming'): game_type_counts['upcoming'] += 1 # Prepare scroll content with mixed game types diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 94d4623..3535d90 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.0.5", + "version": "1.1.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -24,6 +24,11 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-02-13", + "version": "1.1.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-02-12", "version": "1.0.5", @@ -45,7 +50,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-02-12", + "last_updated": "2026-02-13", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/baseball-scoreboard/scorebug_renderer.py b/plugins/baseball-scoreboard/scorebug_renderer.py deleted file mode 100644 index b9b1fb5..0000000 --- a/plugins/baseball-scoreboard/scorebug_renderer.py +++ /dev/null @@ -1,693 +0,0 @@ -""" -Baseball Scorebug Renderer - -Handles all drawing logic for live, recent, and upcoming baseball games. -Replicates functionality from BaseballLive, SportsRecent, and SportsUpcoming. -""" - -import logging -import time -from datetime import datetime -from pathlib import Path -from typing import Any, Dict, Optional - -import pytz -from PIL import Image, ImageDraw, ImageFont -import freetype - -from logo_manager import BaseballLogoManager -from odds_manager import BaseballOddsManager -from rankings_manager import BaseballRankingsManager - - -class BaseballScorebugRenderer: - """Renders scorebugs for all baseball game modes.""" - - def __init__(self, display_manager, logo_manager: BaseballLogoManager, - odds_manager: BaseballOddsManager, - rankings_manager: BaseballRankingsManager, - logger: logging.Logger): - """ - Initialize the scorebug renderer. - - Args: - display_manager: Display manager instance - logo_manager: Logo manager instance - odds_manager: Odds manager instance - rankings_manager: Rankings manager instance - logger: Logger instance - """ - self.display_manager = display_manager - self.logo_manager = logo_manager - self.odds_manager = odds_manager - self.rankings_manager = rankings_manager - self.logger = logger - - # Get display dimensions - if display_manager and hasattr(display_manager, 'matrix') and display_manager.matrix is not None: - self.display_width = display_manager.matrix.width - self.display_height = display_manager.matrix.height - elif display_manager: - # Fallback to width/height properties (which also check matrix) - self.display_width = getattr(display_manager, "width", 128) - self.display_height = getattr(display_manager, "height", 32) - else: - self.display_width = 128 - self.display_height = 32 - - # Load fonts - self.fonts = self._load_fonts() - - def _load_fonts(self) -> Dict: - """ - Load fonts used by the scoreboard. - - Returns: - Dictionary of font objects - """ - fonts = {} - try: - fonts['score'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) - fonts['time'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - fonts['detail'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - fonts['rank'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) - self.logger.info("Successfully loaded fonts") - except IOError: - self.logger.warning("Fonts not found, using default PIL font.") - fonts['score'] = ImageFont.load_default() - fonts['time'] = ImageFont.load_default() - fonts['team'] = ImageFont.load_default() - fonts['status'] = ImageFont.load_default() - fonts['detail'] = ImageFont.load_default() - fonts['rank'] = ImageFont.load_default() - return fonts - - def _draw_text_with_outline(self, draw: ImageDraw.Draw, text: str, position: tuple, - font, fill=(255, 255, 255), outline_color=(0, 0, 0)) -> None: - """ - Draw text with a black outline for better readability. - - Args: - draw: ImageDraw instance - text: Text to draw - position: (x, y) position tuple - font: Font to use - fill: Text color (default: white) - outline_color: Outline color (default: black) - """ - x, y = position - for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - - def render_live_scorebug(self, game: Dict, league_config: Dict, league_key: str = None) -> None: - """ - Render a live game scorebug. - - Replicates BaseballLive._draw_scorebug_layout() logic. - - Args: - game: Game dictionary with all game details - league_config: League-specific configuration - league_key: League identifier (for MiLB-specific handling) - """ - try: - # MiLB uses different rendering - if league_key == 'milb': - self._render_milb_live_scorebug(game, league_config) - return - - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) - - # Load logos - home_abbr = game.get('home_abbr', '') - away_abbr = game.get('away_abbr', '') - home_id = game.get('home_id', '') - away_id = game.get('away_id', '') - home_logo_path = game.get('home_logo_path') - away_logo_path = game.get('away_logo_path') - home_logo_url = game.get('home_logo_url') - away_logo_url = game.get('away_logo_url') - - sport_key = league_key or 'baseball' - home_logo = self.logo_manager.load_logo( - home_id, home_abbr, home_logo_path, home_logo_url, sport_key - ) - away_logo = self.logo_manager.load_logo( - away_id, away_abbr, away_logo_path, away_logo_url, sport_key - ) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for live game: {game.get('id')}") - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5, 5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # Draw logos (shifted slightly inward) - home_x = self.display_width - home_logo.width + 10 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Live Game Specific Elements - text_color = (255, 255, 255) - - # Draw Inning (Top Center) - inning_half = game.get('inning_half', 'top') - inning_num = game.get('inning', 1) - - if game.get('is_final', False): - inning_text = "FINAL" - else: - inning_half_indicator = "▲" if inning_half.lower() == "top" else "▼" - inning_text = f"{inning_half_indicator}{inning_num}" - - # Use display_manager font for inning - inning_font = getattr(self.display_manager, 'font', self.fonts['time']) - try: - inning_bbox = draw_overlay.textbbox((0, 0), inning_text, font=inning_font) - inning_width = inning_bbox[2] - inning_bbox[0] - except AttributeError: - # Fallback for older PIL - inning_width = len(inning_text) * 8 - inning_x = (self.display_width - inning_width) // 2 - inning_y = 1 - self._draw_text_with_outline(draw_overlay, inning_text, (inning_x, inning_y), inning_font) - - # Draw bases and outs - bases_occupied = game.get('bases_occupied', [False, False, False]) - outs = game.get('outs', 0) - - # Define geometry - base_diamond_size = 7 - out_circle_diameter = 3 - out_vertical_spacing = 2 - spacing_between_bases_outs = 3 - base_vert_spacing = 1 - base_horiz_spacing = 1 - - base_cluster_height = base_diamond_size + base_vert_spacing + base_diamond_size - base_cluster_width = base_diamond_size + base_horiz_spacing + base_diamond_size - out_cluster_height = 3 * out_circle_diameter + 2 * out_vertical_spacing - out_cluster_width = out_circle_diameter - - overall_start_y = inning_bbox[3] if 'inning_bbox' in locals() else 9 - - # Center the BASE cluster horizontally - bases_origin_x = (self.display_width - base_cluster_width) // 2 - - # Determine relative positions for outs based on inning half - if inning_half == "top": - outs_column_x = bases_origin_x - spacing_between_bases_outs - out_cluster_width - else: - outs_column_x = bases_origin_x + base_cluster_width + spacing_between_bases_outs - - outs_column_start_y = overall_start_y + (base_cluster_height // 2) - (out_cluster_height // 2) - - # Draw Bases (Diamonds) - base_color_occupied = (255, 255, 255) - base_color_empty = (255, 255, 255) - h_d = base_diamond_size // 2 - - # 2nd Base (Top center) - c2x = bases_origin_x + base_cluster_width // 2 - c2y = overall_start_y + h_d - poly2 = [(c2x, overall_start_y), (c2x + h_d, c2y), (c2x, c2y + h_d), (c2x - h_d, c2y)] - if bases_occupied[1]: - draw_overlay.polygon(poly2, fill=base_color_occupied) - else: - draw_overlay.polygon(poly2, outline=base_color_empty) - - base_bottom_y = c2y + h_d - - # 3rd Base (Bottom left) - c3x = bases_origin_x + h_d - c3y = base_bottom_y + base_vert_spacing + h_d - poly3 = [(c3x, base_bottom_y + base_vert_spacing), (c3x + h_d, c3y), (c3x, c3y + h_d), (c3x - h_d, c3y)] - if bases_occupied[2]: - draw_overlay.polygon(poly3, fill=base_color_occupied) - else: - draw_overlay.polygon(poly3, outline=base_color_empty) - - # 1st Base (Bottom right) - c1x = bases_origin_x + base_cluster_width - h_d - c1y = base_bottom_y + base_vert_spacing + h_d - poly1 = [(c1x, base_bottom_y + base_vert_spacing), (c1x + h_d, c1y), (c1x, c1y + h_d), (c1x - h_d, c1y)] - if bases_occupied[0]: - draw_overlay.polygon(poly1, fill=base_color_occupied) - else: - draw_overlay.polygon(poly1, outline=base_color_empty) - - # Draw Outs (Vertical Circles) - circle_color_out = (255, 255, 255) - circle_color_empty_outline = (100, 100, 100) - - for i in range(3): - cx = outs_column_x - cy = outs_column_start_y + i * (out_circle_diameter + out_vertical_spacing) - coords = [cx, cy, cx + out_circle_diameter, cy + out_circle_diameter] - if i < outs: - draw_overlay.ellipse(coords, fill=circle_color_out) - else: - draw_overlay.ellipse(coords, outline=circle_color_empty_outline) - - # Draw Balls-Strikes Count - balls = game.get('balls', 0) - strikes = game.get('strikes', 0) - count_text = f"{balls}-{strikes}" - - # Try to use BDF font if available, otherwise fallback to TTF - bdf_font = getattr(self.display_manager, 'calendar_font', None) - if bdf_font and isinstance(bdf_font, freetype.Face): - try: - bdf_font.set_char_size(height=7 * 64) - count_text_width = self.display_manager.get_text_width(count_text, bdf_font) - using_bdf = True - except Exception: - count_text_width = draw_overlay.textlength(count_text, font=self.fonts['detail']) - using_bdf = False - else: - count_text_width = draw_overlay.textlength(count_text, font=self.fonts['detail']) - using_bdf = False - - cluster_bottom_y = overall_start_y + base_cluster_height - count_y = cluster_bottom_y + 2 - count_x = bases_origin_x + (base_cluster_width - count_text_width) // 2 - - if using_bdf: - self.display_manager.draw = draw_overlay - outline_color_for_bdf = (0, 0, 0) - for dx_offset, dy_offset in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: - self.display_manager._draw_bdf_text( - count_text, count_x + dx_offset, count_y + dy_offset, - color=outline_color_for_bdf, font=bdf_font - ) - self.display_manager._draw_bdf_text(count_text, count_x, count_y, color=text_color, font=bdf_font) - else: - self._draw_text_with_outline(draw_overlay, count_text, (count_x, count_y), self.fonts['detail']) - - # Draw Team:Score at the bottom - score_font = getattr(self.display_manager, 'font', self.fonts['score']) - score_text_color = (255, 255, 255) - outline_color = (0, 0, 0) - - away_score_str = str(game.get('away_score', '0')) - home_score_str = str(game.get('home_score', '0')) - away_text = f"{away_abbr}:{away_score_str}" - home_text = f"{home_abbr}:{home_score_str}" - - try: - font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1] - except AttributeError: - font_height = 8 - score_y = self.display_height - font_height - 2 - - # Away Team:Score (Bottom Left) - away_score_x = 2 - self._draw_text_with_outline(draw_overlay, away_text, (away_score_x, score_y), score_font, fill=score_text_color, outline_color=outline_color) - - # Home Team:Score (Bottom Right) - try: - home_text_bbox = draw_overlay.textbbox((0, 0), home_text, font=score_font) - home_text_width = home_text_bbox[2] - home_text_bbox[0] - except AttributeError: - home_text_width = len(home_text) * 8 - home_score_x = self.display_width - home_text_width - 2 - self._draw_text_with_outline(draw_overlay, home_text, (home_score_x, score_y), score_font, fill=score_text_color, outline_color=outline_color) - - # Draw odds if available - if 'odds' in game and game['odds']: - self.odds_manager.render_odds(draw_overlay, game['odds'], self.display_width, self.display_height, self.fonts) - - # Composite and display - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() - - except Exception as e: - self.logger.error(f"Error rendering live scorebug: {e}", exc_info=True) - - def render_recent_scorebug(self, game: Dict, league_config: Dict, league_key: str = None) -> None: - """ - Render a recent (final) game scorebug. - - Replicates SportsRecent._draw_scorebug_layout() logic. - - Args: - game: Game dictionary - league_config: League-specific configuration - league_key: League identifier - """ - try: - # MiLB uses different rendering - if league_key == 'milb': - self._render_milb_recent_scorebug(game, league_config) - return - - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) - - # Load logos - home_abbr = game.get('home_abbr', '') - away_abbr = game.get('away_abbr', '') - home_id = game.get('home_id', '') - away_id = game.get('away_id', '') - home_logo_path = game.get('home_logo_path') - away_logo_path = game.get('away_logo_path') - home_logo_url = game.get('home_logo_url') - away_logo_url = game.get('away_logo_url') - - sport_key = league_key or 'baseball' - home_logo = self.logo_manager.load_logo( - home_id, home_abbr, home_logo_path, home_logo_url, sport_key - ) - away_logo = self.logo_manager.load_logo( - away_id, away_abbr, away_logo_path, away_logo_url, sport_key - ) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for recent game: {game.get('id')}") - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5, 5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # MLB-style logo positioning - home_x = self.display_width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -2 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Final Scores (Centered) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = self.display_height - 14 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) - - # "Final" text (Top center) - status_text = game.get("period_text", "Final") - status_width = draw_overlay.textlength(status_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 1 - self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), self.fonts['time']) - - # Draw odds if available - if 'odds' in game and game['odds']: - self.odds_manager.render_odds(draw_overlay, game['odds'], self.display_width, self.display_height, self.fonts) - - # Draw records or rankings if enabled - show_records = league_config.get('show_records', False) - show_ranking = league_config.get('show_ranking', False) - - if show_records or show_ranking: - try: - record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - except IOError: - record_font = ImageFont.load_default() - - record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - - # Display away team info - if away_abbr: - away_text = self._get_team_display_text( - away_abbr, game, league_config, league_key, show_records, show_ranking - ) - if away_text: - away_record_x = 0 - self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - - # Display home team info - if home_abbr: - home_text = self._get_team_display_text( - home_abbr, game, league_config, league_key, show_records, show_ranking - ) - if home_text: - home_record_bbox = draw_overlay.textbbox((0, 0), home_text, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) - - # Draw series summary if enabled (replicates BaseballRecent.display_series_summary) - show_series_summary = league_config.get('show_series_summary', False) - if show_series_summary: - series_summary = game.get("series_summary", "") - if series_summary: - try: - series_font = self.fonts['time'] - series_bbox = draw_overlay.textbbox((0, 0), series_summary, font=series_font) - series_height = series_bbox[3] - series_bbox[1] - series_y = (self.display_height - series_height) // 2 - series_width = draw_overlay.textlength(series_summary, font=series_font) - series_x = (self.display_width - series_width) // 2 - self._draw_text_with_outline(draw_overlay, series_summary, (series_x, series_y), series_font) - except Exception as e: - self.logger.warning(f"Error drawing series summary: {e}") - - # Composite and display - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() - - except Exception as e: - self.logger.error(f"Error rendering recent scorebug: {e}", exc_info=True) - - def render_upcoming_scorebug(self, game: Dict, league_config: Dict, league_key: str = None) -> None: - """ - Render an upcoming game scorebug. - - Replicates SportsUpcoming._draw_scorebug_layout() logic. - - Args: - game: Game dictionary - league_config: League-specific configuration - league_key: League identifier - """ - try: - # MiLB uses different rendering - if league_key == 'milb': - self._render_milb_upcoming_scorebug(game, league_config) - return - - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) - - # Load logos - home_abbr = game.get('home_abbr', '') - away_abbr = game.get('away_abbr', '') - home_id = game.get('home_id', '') - away_id = game.get('away_id', '') - home_logo_path = game.get('home_logo_path') - away_logo_path = game.get('away_logo_path') - home_logo_url = game.get('home_logo_url') - away_logo_url = game.get('away_logo_url') - - sport_key = league_key or 'baseball' - home_logo = self.logo_manager.load_logo( - home_id, home_abbr, home_logo_path, home_logo_url, sport_key - ) - away_logo = self.logo_manager.load_logo( - away_id, away_abbr, away_logo_path, away_logo_url, sport_key - ) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for upcoming game: {game.get('id')}") - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5, 5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # MLB-style logo positions - home_x = self.display_width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -2 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw Text Elements on Overlay - game_date = game.get("game_date", "") - game_time = game.get("game_time", "") - - # "Next Game" at the top - status_font = self.fonts['status'] - if self.display_width > 128: - status_font = self.fonts['time'] - status_text = "Next Game" - status_width = draw_overlay.textlength(status_text, font=status_font) - status_x = (self.display_width - status_width) // 2 - status_y = 1 - self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), status_font) - - # Date text (centered, below "Next Game") - date_width = draw_overlay.textlength(game_date, font=self.fonts['time']) - date_x = (self.display_width - date_width) // 2 - date_y = center_y - 7 - self._draw_text_with_outline(draw_overlay, game_date, (date_x, date_y), self.fonts['time']) - - # Time text (centered, below Date) - time_width = draw_overlay.textlength(game_time, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 - time_y = date_y + 9 - self._draw_text_with_outline(draw_overlay, game_time, (time_x, time_y), self.fonts['time']) - - # Draw odds if available - if 'odds' in game and game['odds']: - self.odds_manager.render_odds(draw_overlay, game['odds'], self.display_width, self.display_height, self.fonts) - - # Draw records or rankings if enabled - show_records = league_config.get('show_records', False) - show_ranking = league_config.get('show_ranking', False) - - if show_records or show_ranking: - try: - record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - except IOError: - record_font = ImageFont.load_default() - - record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - - # Display away team info - if away_abbr: - away_text = self._get_team_display_text( - away_abbr, game, league_config, league_key, show_records, show_ranking - ) - if away_text: - away_record_x = 0 - self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - - # Display home team info - if home_abbr: - home_text = self._get_team_display_text( - home_abbr, game, league_config, league_key, show_records, show_ranking - ) - if home_text: - home_record_bbox = draw_overlay.textbbox((0, 0), home_text, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) - - # Composite and display - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() - - except Exception as e: - self.logger.error(f"Error rendering upcoming scorebug: {e}", exc_info=True) - - def _get_team_display_text(self, team_abbr: str, game: Dict, league_config: Dict, - league_key: str, show_records: bool, show_ranking: bool) -> str: - """ - Get display text for team (ranking or record). - - Args: - team_abbr: Team abbreviation - game: Game dictionary - league_config: League-specific configuration - league_key: League identifier - show_records: Whether to show records - show_ranking: Whether to show rankings - - Returns: - Display text string - """ - if show_ranking and show_records: - # When both rankings and records are enabled, rankings replace records completely - team_rank = self.rankings_manager.get_team_rank(team_abbr, league_key or 'baseball') - if team_rank > 0: - return f"#{team_rank}" - return '' - elif show_ranking: - # Show ranking only if available - team_rank = self.rankings_manager.get_team_rank(team_abbr, league_key or 'baseball') - if team_rank > 0: - return f"#{team_rank}" - return '' - elif show_records: - # Show record only when rankings are disabled - if team_abbr == game.get('away_abbr', ''): - return game.get('away_record', '') - elif team_abbr == game.get('home_abbr', ''): - return game.get('home_record', '') - return '' - return '' - - def _render_milb_live_scorebug(self, game: Dict, league_config: Dict) -> None: - """ - Render MiLB live game scorebug (replicates MiLBLiveManager._create_live_game_display). - - Args: - game: Game dictionary - league_config: League-specific configuration - """ - try: - from src.old_managers.milb_manager import MiLBLiveManager - - # For now, use a simplified version that matches ESPN format - # Full MiLB rendering would require MiLB-specific logic from milb_manager.py - # This is a placeholder that renders in ESPN format - self.render_live_scorebug(game, league_config, league_key='milb') - - except Exception as e: - self.logger.error(f"Error rendering MiLB live scorebug: {e}", exc_info=True) - - def _render_milb_recent_scorebug(self, game: Dict, league_config: Dict) -> None: - """ - Render MiLB recent game scorebug. - - Args: - game: Game dictionary - league_config: League-specific configuration - """ - try: - # Simplified version - full implementation would match MiLB manager - self.render_recent_scorebug(game, league_config, league_key='milb') - except Exception as e: - self.logger.error(f"Error rendering MiLB recent scorebug: {e}", exc_info=True) - - def _render_milb_upcoming_scorebug(self, game: Dict, league_config: Dict) -> None: - """ - Render MiLB upcoming game scorebug. - - Args: - game: Game dictionary - league_config: League-specific configuration - """ - try: - # Simplified version - full implementation would match MiLB manager - self.render_upcoming_scorebug(game, league_config, league_key='milb') - except Exception as e: - self.logger.error(f"Error rendering MiLB upcoming scorebug: {e}", exc_info=True) - diff --git a/plugins/baseball-scoreboard/scroll_display.py b/plugins/baseball-scoreboard/scroll_display.py index 0edb8bd..7f93fd9 100644 --- a/plugins/baseball-scoreboard/scroll_display.py +++ b/plugins/baseball-scoreboard/scroll_display.py @@ -279,12 +279,11 @@ def _determine_game_type(self, game: Dict) -> str: Returns: Game type: 'live', 'recent', or 'upcoming' """ - state = game.get('status', {}).get('state', '') - if state == 'in': + if game.get('is_live'): return 'live' - elif state == 'post': + elif game.get('is_final'): return 'recent' - elif state == 'pre': + elif game.get('is_upcoming'): return 'upcoming' else: # Default to upcoming if state is unknown From ab3b8421251776f23ea729d21200a5a8fc07f51c Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 13 Feb 2026 21:20:51 -0500 Subject: [PATCH 2/6] fix(baseball): fix inning display, count fallback, and records config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix 'end of inning' rendering: don't increment period when 'end' is detected in status text, use distinct 'end'/'mid' inning_half values and render as E5/M5 markers instead of misleading ▲ arrows - Fix count fallback: check whether count dict is present/populated rather than testing for 0-0 values, which treated valid 0-0 counts as missing data - Fix game_renderer.py _draw_records: gate on show_records/show_ranking config flags from league_config to match manager.py behavior, so scroll mode respects user settings Co-Authored-By: Claude Opus 4.6 --- plugins/baseball-scoreboard/game_renderer.py | 13 ++++++++++- plugins/baseball-scoreboard/manager.py | 24 ++++++++++++-------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 7d54bc2..a67fa5e 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -169,6 +169,10 @@ def _render_live_game(self, game: Dict) -> Image.Image: inning_num = game.get('inning', 1) if game.get('is_final'): inning_text = "FINAL" + elif inning_half == 'end': + inning_text = f"E{inning_num}" + elif inning_half == 'mid': + inning_text = f"M{inning_num}" else: symbol = "▲" if inning_half == 'top' else "▼" inning_text = f"{symbol}{inning_num}" @@ -378,7 +382,14 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: return self._render_error_card("Display error") def _draw_records(self, draw, game: Dict): - """Draw team records at bottom corners.""" + """Draw team records at bottom corners if enabled by config.""" + league_config = game.get('league_config', {}) + show_records = league_config.get('show_records', self.config.get('show_records', False)) + show_ranking = league_config.get('show_ranking', self.config.get('show_ranking', False)) + + if not show_records and not show_ranking: + return + away_record = game.get('away_record', '') home_record = game.get('home_record', '') if not away_record and not home_record: diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 59e333e..bb1d6ba 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -566,31 +566,33 @@ def _extract_game_info(self, event: Dict, league_key: str, league_config: Dict) # Determine inning half from status text inning_half = 'top' if 'end' in status_detail or 'end' in status_short: - inning_half = 'top' - inning = status.get('period', 1) + 1 + inning_half = 'end' elif 'mid' in status_detail or 'mid' in status_short: - inning_half = 'bottom' + inning_half = 'mid' elif 'bottom' in status_detail or 'bot' in status_detail or 'bottom' in status_short or 'bot' in status_short: inning_half = 'bottom' elif 'top' in status_detail or 'top' in status_short: inning_half = 'top' # Get count and bases from situation - count = situation.get('count', {}) if situation else {} - balls = count.get('balls', 0) - strikes = count.get('strikes', 0) + count = situation.get('count') if situation else None outs = situation.get('outs', 0) if situation else 0 - # Try alternative locations for count data - if balls == 0 and strikes == 0 and situation: + if count: + balls = count.get('balls', 0) + strikes = count.get('strikes', 0) + elif situation: + # Try alternative locations for count data if 'summary' in situation: try: balls, strikes = map(int, situation['summary'].split('-')) except (ValueError, AttributeError): - pass + balls, strikes = 0, 0 else: balls = situation.get('balls', 0) strikes = situation.get('strikes', 0) + else: + balls, strikes = 0, 0 bases_occupied = [ situation.get('onFirst', False) if situation else False, @@ -901,6 +903,10 @@ def _display_live_game(self, game: Dict): inning_num = game.get('inning', 1) if game.get('is_final'): inning_text = "FINAL" + elif inning_half == 'end': + inning_text = f"E{inning_num}" + elif inning_half == 'mid': + inning_text = f"M{inning_num}" else: symbol = "▲" if inning_half == 'top' else "▼" inning_text = f"{symbol}{inning_num}" From 5db7e6188e8383c706542c00f301e66786846fde Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 13 Feb 2026 21:45:53 -0500 Subject: [PATCH 3/6] feat(baseball): integrate odds rendering, fix scroll logo paths - Wire up BaseballOddsManager in manager.py: import, initialize, and call fetch_odds/render_odds in all three display methods (live, recent, upcoming) when show_odds is enabled - Add _draw_dynamic_odds() to game_renderer.py for scroll mode cards, matching the pattern used by football/basketball plugins - Fix game_renderer.py _get_logo_path to read logo_dir from league_config instead of hardcoding paths, so custom logo directories (MiLB, NCAA) are respected in scroll mode - Move datetime/pytz imports to module level in game_renderer.py Co-Authored-By: Claude Opus 4.6 --- plugins.json | 2 +- plugins/baseball-scoreboard/game_renderer.py | 104 ++++++++++++++++--- plugins/baseball-scoreboard/manager.py | 40 +++++++ plugins/baseball-scoreboard/manifest.json | 9 +- 4 files changed, 139 insertions(+), 16 deletions(-) diff --git a/plugins.json b/plugins.json index f8fe62a..233fe86 100644 --- a/plugins.json +++ b/plugins.json @@ -299,7 +299,7 @@ "last_updated": "2026-02-13", "verified": true, "screenshot": "", - "latest_version": "1.1.0" + "latest_version": "1.2.0" }, { "id": "soccer-scoreboard", diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index a67fa5e..c25844f 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -6,9 +6,11 @@ """ import logging +from datetime import datetime from pathlib import Path from typing import Dict, Optional +import pytz from PIL import Image, ImageDraw, ImageFont # Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 @@ -72,8 +74,14 @@ def _load_fonts(self): fonts['rank'] = ImageFont.load_default() return fonts - def _get_logo_path(self, league: str, team_abbrev: str) -> Path: - """Get the logo path for a team based on league.""" + def _get_logo_path(self, league: str, team_abbrev: str, game: Dict = None) -> Path: + """Get the logo path for a team based on league config.""" + # Use league_config logo_dir if available + if game and game.get('league_config'): + logo_dir = game['league_config'].get('logo_dir') + if logo_dir: + return Path(logo_dir) / f"{team_abbrev}.png" + # Fallback to defaults if league == 'mlb': return Path("assets/sports/mlb_logos") / f"{team_abbrev}.png" elif league == 'milb': @@ -83,13 +91,13 @@ def _get_logo_path(self, league: str, team_abbrev: str) -> Path: else: return Path("assets/sports/mlb_logos") / f"{team_abbrev}.png" - def _load_and_resize_logo(self, league: str, team_abbrev: str) -> Optional[Image.Image]: + def _load_and_resize_logo(self, league: str, team_abbrev: str, game: Dict = None) -> Optional[Image.Image]: """Load and resize a team logo, with caching.""" cache_key = f"{league}_{team_abbrev}" if cache_key in self._logo_cache: return self._logo_cache[cache_key] - logo_path = self._get_logo_path(league, team_abbrev) + logo_path = self._get_logo_path(league, team_abbrev, game) if not logo_path.exists(): self.logger.warning(f"Logo not found for {team_abbrev} at {logo_path}") @@ -152,8 +160,8 @@ def _render_live_game(self, game: Dict) -> Image.Image: draw = ImageDraw.Draw(overlay) league = game.get('league', 'mlb') - home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) - away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', ''), game) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', ''), game) if not home_logo or not away_logo: return self._render_error_card("Logo Error") @@ -270,6 +278,10 @@ def _render_live_game(self, game: Dict) -> Image.Image: home_w = len(home_text) * 8 self._draw_text_with_outline(draw, home_text, (self.display_width - home_w - 2, score_y), score_font) + # Odds + if game.get('odds'): + self._draw_dynamic_odds(draw, game['odds']) + main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -285,8 +297,8 @@ def _render_recent_game(self, game: Dict) -> Image.Image: draw = ImageDraw.Draw(overlay) league = game.get('league', 'mlb') - home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) - away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', ''), game) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', ''), game) if not home_logo or not away_logo: return self._render_error_card("Logo Error") @@ -312,6 +324,10 @@ def _render_recent_game(self, game: Dict) -> Image.Image: # Records at bottom corners self._draw_records(draw, game) + # Odds + if game.get('odds'): + self._draw_dynamic_odds(draw, game['odds']) + main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -327,8 +343,8 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: draw = ImageDraw.Draw(overlay) league = game.get('league', 'mlb') - home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) - away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', ''), game) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', ''), game) if not home_logo or not away_logo: return self._render_error_card("Logo Error") @@ -351,14 +367,12 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: game_time = '' if start_time: try: - from datetime import datetime - import pytz dt = datetime.fromisoformat(start_time.replace('Z', '+00:00')) local_tz = pytz.timezone(self.config.get('timezone', 'US/Eastern')) dt_local = dt.astimezone(local_tz) game_date = dt_local.strftime('%b %d') game_time = dt_local.strftime('%-I:%M %p') - except (ValueError, AttributeError, ImportError): + except (ValueError, AttributeError): game_time = start_time[:10] if len(start_time) > 10 else start_time time_font = self.fonts['time'] @@ -374,6 +388,10 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: # Records at bottom corners self._draw_records(draw, game) + # Odds + if game.get('odds'): + self._draw_dynamic_odds(draw, game['odds']) + main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -407,6 +425,66 @@ def _draw_records(self, draw, game: Dict): home_w = home_bbox[2] - home_bbox[0] self._draw_text_with_outline(draw, home_record, (self.display_width - home_w, record_y), record_font) + def _draw_dynamic_odds(self, draw, odds: Dict) -> None: + """Draw odds with dynamic positioning based on favored team.""" + try: + if not odds: + return + + home_team_odds = odds.get('home_team_odds', {}) + away_team_odds = odds.get('away_team_odds', {}) + home_spread = home_team_odds.get('spread_odds') + away_spread = away_team_odds.get('spread_odds') + + # Get top-level spread as fallback + top_level_spread = odds.get('spread') + if top_level_spread is not None: + if home_spread is None or home_spread == 0.0: + home_spread = top_level_spread + if away_spread is None: + away_spread = -top_level_spread + + # Determine favored team + home_favored = isinstance(home_spread, (int, float)) and home_spread < 0 + away_favored = isinstance(away_spread, (int, float)) and away_spread < 0 + + favored_spread = None + favored_side = None + + if home_favored: + favored_spread = home_spread + favored_side = 'home' + elif away_favored: + favored_spread = away_spread + favored_side = 'away' + + # Show the negative spread on the appropriate side + font = self.fonts['detail'] + if favored_spread is not None: + spread_text = str(favored_spread) + spread_width = draw.textlength(spread_text, font=font) + if favored_side == 'home': + spread_x = self.display_width - spread_width + else: + spread_x = 0 + self._draw_text_with_outline(draw, spread_text, (spread_x, 0), font, fill=(0, 255, 0)) + + # Show over/under on opposite side + over_under = odds.get('over_under') + if over_under is not None and isinstance(over_under, (int, float)): + ou_text = f"O/U: {over_under}" + ou_width = draw.textlength(ou_text, font=font) + if favored_side == 'home': + ou_x = 0 + elif favored_side == 'away': + ou_x = self.display_width - ou_width + else: + ou_x = (self.display_width - ou_width) // 2 + self._draw_text_with_outline(draw, ou_text, (ou_x, 0), font, fill=(0, 255, 0)) + + except Exception as e: + self.logger.error(f"Error drawing odds: {e}") + def _render_error_card(self, message: str) -> Image.Image: """Render an error message card.""" img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index bb1d6ba..06571cb 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -54,6 +54,12 @@ except ImportError: ScrollDisplayManager = None SCROLL_AVAILABLE = False + +# Import odds manager +try: + from odds_manager import BaseballOddsManager +except ImportError: + BaseballOddsManager = None logger = logging.getLogger(__name__) @@ -190,6 +196,15 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], else: self.logger.info("Scroll display not available - scroll mode disabled") + # Initialize odds manager + self._odds_manager = None + if BaseballOddsManager: + try: + self._odds_manager = BaseballOddsManager(cache_manager, logger=self.logger) + self.logger.info("Baseball odds manager initialized") + except Exception as e: + self.logger.warning(f"Could not initialize odds manager: {e}") + self.initialized = True # Initialize data manager for background fetching @@ -883,6 +898,22 @@ def _draw_records(self, draw: ImageDraw.Draw, game: Dict, width: int, height: in home_w = home_bbox[2] - home_bbox[0] self._draw_text_with_outline(draw, home_text, (width - home_w, record_y), record_font) + def _fetch_and_render_odds(self, draw: ImageDraw.Draw, game: Dict, width: int, height: int): + """Fetch and render odds for a game if enabled.""" + if not self._odds_manager: + return + + league_config = game.get('league_config', {}) + league = game.get('league', 'mlb') + show_odds = league_config.get('show_odds', self.config.get('show_odds', False)) + if not show_odds: + return + + self._odds_manager.fetch_odds(game, league_config, 'baseball', league) + odds = game.get('odds') + if odds: + self._odds_manager.render_odds(draw, odds, width, height, self.fonts) + def _display_live_game(self, game: Dict): """Display a live baseball game with full scorebug: bases, outs, count, inning.""" matrix_width = self.display_manager.matrix.width @@ -1030,6 +1061,9 @@ def _display_live_game(self, game: Dict): home_text_width = len(home_text) * 8 self._draw_text_with_outline(draw, home_text, (matrix_width - home_text_width - 2, score_y), score_font) + # Odds + self._fetch_and_render_odds(draw, game, matrix_width, matrix_height) + # Composite and display final_img = Image.alpha_composite(main_img, overlay) self.display_manager.image = final_img.convert('RGB').copy() @@ -1082,6 +1116,9 @@ def _display_recent_game(self, game: Dict): series_y = (matrix_height - series_height) // 2 self._draw_text_with_outline(draw, series_summary, (series_x, series_y), series_font) + # Odds + self._fetch_and_render_odds(draw, game, matrix_width, matrix_height) + # Composite and display final_img = Image.alpha_composite(main_img, overlay) self.display_manager.image = final_img.convert('RGB').copy() @@ -1144,6 +1181,9 @@ def _display_upcoming_game(self, game: Dict): # Records at bottom corners self._draw_records(draw, game, matrix_width, matrix_height) + # Odds + self._fetch_and_render_odds(draw, game, matrix_width, matrix_height) + # Composite and display final_img = Image.alpha_composite(main_img, overlay) self.display_manager.image = final_img.convert('RGB').copy() diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 3535d90..e25ca86 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.1.0", + "version": "1.2.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -24,6 +24,11 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-02-14", + "version": "1.2.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-02-13", "version": "1.1.0", @@ -50,7 +55,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-02-13", + "last_updated": "2026-02-14", "stars": 0, "downloads": 0, "verified": true, From b757986fa95dd837d198b1d370f12c4d66cd6653 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 13 Feb 2026 22:12:09 -0500 Subject: [PATCH 4/6] feat(baseball): integrate logo manager and rankings manager - Wire up BaseballLogoManager for auto-download of missing logos via ESPN API, with fallback to inline logo loading when unavailable - Wire up BaseballRankingsManager to fetch real team rankings (AP Top 25 etc.) from ESPN standings API, cached for 1 hour - Update _draw_records in both manager.py and game_renderer.py to show "#rank" (e.g., "#5") when show_ranking is enabled, matching the football/basketball pattern - Add _get_team_display_text helper for consistent ranking/record display logic across switch and scroll modes - Pass rankings cache through scroll_display.py to GameRenderer via set_rankings_cache() for scroll mode support - Version bump to 1.3.0 Co-Authored-By: Claude Opus 4.6 --- plugins.json | 2 +- plugins/baseball-scoreboard/game_renderer.py | 47 ++++-- plugins/baseball-scoreboard/manager.py | 139 +++++++++++++++--- plugins/baseball-scoreboard/manifest.json | 7 +- plugins/baseball-scoreboard/scroll_display.py | 7 +- 5 files changed, 165 insertions(+), 37 deletions(-) diff --git a/plugins.json b/plugins.json index 233fe86..cd8bcde 100644 --- a/plugins.json +++ b/plugins.json @@ -299,7 +299,7 @@ "last_updated": "2026-02-13", "verified": true, "screenshot": "", - "latest_version": "1.2.0" + "latest_version": "1.3.0" }, { "id": "soccer-scoreboard", diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index c25844f..a2f1564 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -50,6 +50,9 @@ def __init__( # Use provided logo cache or create new one self._logo_cache = logo_cache if logo_cache is not None else {} + # Rankings cache (populated externally via set_rankings_cache) + self._team_rankings_cache: Dict[str, int] = {} + # Load fonts self.fonts = self._load_fonts() @@ -131,6 +134,10 @@ def _draw_text_with_outline(self, draw, text, position, font, draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) + def set_rankings_cache(self, rankings: Dict[str, int]) -> None: + """Set the team rankings cache for display.""" + self._team_rankings_cache = rankings + def render_game_card(self, game: Dict, game_type: str) -> Image.Image: """ Render a game card as a PIL Image. @@ -399,8 +406,20 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: self.logger.exception("Error rendering upcoming game") return self._render_error_card("Display error") + def _get_team_display_text(self, abbr: str, record: str, show_records: bool, show_ranking: bool) -> str: + """Get display text for a team (ranking or record).""" + if show_ranking: + rank = self._team_rankings_cache.get(abbr, 0) + if rank > 0: + return f"#{rank}" + if not show_records: + return '' + if show_records: + return record + return '' + def _draw_records(self, draw, game: Dict): - """Draw team records at bottom corners if enabled by config.""" + """Draw team records or rankings at bottom corners if enabled by config.""" league_config = game.get('league_config', {}) show_records = league_config.get('show_records', self.config.get('show_records', False)) show_ranking = league_config.get('show_ranking', self.config.get('show_ranking', False)) @@ -408,22 +427,28 @@ def _draw_records(self, draw, game: Dict): if not show_records and not show_ranking: return - away_record = game.get('away_record', '') - home_record = game.get('home_record', '') - if not away_record and not home_record: - return - record_font = self.fonts['detail'] record_bbox = draw.textbbox((0, 0), "0-0", font=record_font) record_height = record_bbox[3] - record_bbox[1] record_y = self.display_height - record_height - if away_record: - self._draw_text_with_outline(draw, away_record, (0, record_y), record_font) - if home_record: - home_bbox = draw.textbbox((0, 0), home_record, font=record_font) + # Away team (bottom left) + away_text = self._get_team_display_text( + game.get('away_abbr', ''), game.get('away_record', ''), + show_records, show_ranking + ) + if away_text: + self._draw_text_with_outline(draw, away_text, (0, record_y), record_font) + + # Home team (bottom right) + home_text = self._get_team_display_text( + game.get('home_abbr', ''), game.get('home_record', ''), + show_records, show_ranking + ) + if home_text: + home_bbox = draw.textbbox((0, 0), home_text, font=record_font) home_w = home_bbox[2] - home_bbox[0] - self._draw_text_with_outline(draw, home_record, (self.display_width - home_w, record_y), record_font) + self._draw_text_with_outline(draw, home_text, (self.display_width - home_w, record_y), record_font) def _draw_dynamic_odds(self, draw, odds: Dict) -> None: """Draw odds with dynamic positioning based on favored team.""" diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 06571cb..d8f9069 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -60,6 +60,19 @@ from odds_manager import BaseballOddsManager except ImportError: BaseballOddsManager = None + +# Import logo manager for auto-download support +try: + from logo_manager import BaseballLogoManager +except ImportError: + BaseballLogoManager = None + +# Import rankings manager +try: + from rankings_manager import BaseballRankingsManager +except ImportError: + BaseballRankingsManager = None + logger = logging.getLogger(__name__) @@ -205,6 +218,25 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], except Exception as e: self.logger.warning(f"Could not initialize odds manager: {e}") + # Initialize logo manager for auto-download support + self._logo_manager = None + if BaseballLogoManager: + try: + self._logo_manager = BaseballLogoManager(display_manager, self.logger) + self.logger.info("Baseball logo manager initialized") + except Exception as e: + self.logger.warning(f"Could not initialize logo manager: {e}") + + # Initialize rankings manager + self._rankings_manager = None + self._team_rankings_cache: Dict[str, int] = {} + if BaseballRankingsManager: + try: + self._rankings_manager = BaseballRankingsManager(self.logger) + self.logger.info("Baseball rankings manager initialized") + except Exception as e: + self.logger.warning(f"Could not initialize rankings manager: {e}") + self.initialized = True # Initialize data manager for background fetching @@ -349,6 +381,10 @@ def update(self) -> None: game['league_config'] = league_config new_games.extend(games) + # Fetch rankings if enabled + if self.show_ranking and self._rankings_manager: + self._fetch_all_rankings() + # Update shared state under lock (protected by lock for thread safety) with self._games_lock: self.current_games = new_games @@ -377,6 +413,32 @@ def sort_key(game): self.current_games.sort(key=sort_key) + def _fetch_all_rankings(self): + """Fetch team rankings for all enabled leagues that support rankings.""" + if not self._rankings_manager: + return + + # ESPN league identifiers for rankings API + league_mappings = { + 'mlb': ('baseball', 'mlb'), + 'ncaa_baseball': ('baseball', 'college-baseball'), + } + + for league_key, league_config in self.leagues.items(): + if not league_config.get('enabled', False): + continue + if league_key not in league_mappings: + continue + + sport, league_id = league_mappings[league_key] + rankings = self._rankings_manager.fetch_rankings(sport, league_id, league_key) + if rankings: + self._team_rankings_cache.update(rankings) + + def _get_rankings_cache(self) -> Dict[str, int]: + """Get the combined team rankings cache.""" + return self._team_rankings_cache + def _fetch_league_data(self, league_key: str, league_config: Dict) -> List[Dict]: """Fetch game data for a specific league. @@ -764,8 +826,8 @@ def get_live_modes(self) -> list: """ return ['baseball_live'] - def _load_team_logo(self, team_abbrev: str, league: str) -> Optional[Image.Image]: - """Load and resize team logo.""" + def _load_team_logo(self, team_abbrev: str, league: str, game: Dict = None) -> Optional[Image.Image]: + """Load and resize team logo, with auto-download via logo manager if available.""" try: if not team_abbrev: return None @@ -788,25 +850,46 @@ def _load_team_logo(self, team_abbrev: str, league: str) -> Optional[Image.Image else: logo_dir = os.path.abspath(logo_dir) - # Try different case variations and extensions + logo_path = Path(logo_dir) / f"{team_abbrev}.png" + + # Use logo manager if available (supports auto-download of missing logos) + if self._logo_manager: + team_id = '' + logo_url = None + if game: + side = 'home' if game.get('home_abbr') == team_abbrev else 'away' + team_id = game.get(f'{side}_id', '') + logo_url = game.get(f'{side}_logo_url') + + if league == 'milb': + logo = self._logo_manager.load_milb_logo(team_abbrev, Path(logo_dir)) + else: + sport_key = 'college-baseball' if league == 'ncaa_baseball' else 'baseball' + logo = self._logo_manager.load_logo( + team_id, team_abbrev, logo_path, + logo_url=logo_url, sport_key=sport_key + ) + if logo: + return logo + + # Fallback: inline logo loading (no auto-download) logo_extensions = ['.png', '.jpg', '.jpeg'] - logo_path = None + found_path = None abbrev_variations = [team_abbrev.upper(), team_abbrev.lower(), team_abbrev] for abbrev in abbrev_variations: for ext in logo_extensions: potential_path = os.path.join(logo_dir, f"{abbrev}{ext}") if os.path.exists(potential_path): - logo_path = potential_path + found_path = potential_path break - if logo_path: + if found_path: break - if not logo_path: + if not found_path: return None - # Load and resize logo (matching original managers) - logo = Image.open(logo_path).convert('RGBA') + logo = Image.open(found_path).convert('RGBA') max_width = int(self.display_manager.matrix.width * 1.5) max_height = int(self.display_manager.matrix.height * 1.5) logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) @@ -847,8 +930,8 @@ def _display_game(self, game: Dict, mode: str): def _paste_logos(self, main_img: Image.Image, game: Dict, inward_offset: int = 10): """Load and paste team logos onto the image. Returns (home_logo, away_logo) or (None, None).""" league = game.get('league', '') - home_logo = self._load_team_logo(game.get('home_abbr', ''), league) - away_logo = self._load_team_logo(game.get('away_abbr', ''), league) + home_logo = self._load_team_logo(game.get('home_abbr', ''), league, game) + away_logo = self._load_team_logo(game.get('away_abbr', ''), league, game) if not home_logo or not away_logo: return None, None @@ -864,6 +947,19 @@ def _paste_logos(self, main_img: Image.Image, game: Dict, inward_offset: int = 1 return home_logo, away_logo + def _get_team_display_text(self, abbr: str, record: str, show_records: bool, show_ranking: bool) -> str: + """Get display text for a team (ranking or record), matching football/basketball pattern.""" + if show_ranking: + rank = self._team_rankings_cache.get(abbr, 0) + if rank > 0: + return f"#{rank}" + # Fall through to records if unranked + if not show_records: + return '' + if show_records: + return record + return '' + def _draw_records(self, draw: ImageDraw.Draw, game: Dict, width: int, height: int): """Draw team records or rankings at bottom corners if enabled.""" league_config = game.get('league_config', {}) @@ -879,20 +975,18 @@ def _draw_records(self, draw: ImageDraw.Draw, game: Dict, width: int, height: in record_y = height - record_height # Away team (bottom left) - away_text = '' - if show_ranking: - away_text = game.get('away_record', '') # rankings would replace this if available - elif show_records: - away_text = game.get('away_record', '') + away_text = self._get_team_display_text( + game.get('away_abbr', ''), game.get('away_record', ''), + show_records, show_ranking + ) if away_text: self._draw_text_with_outline(draw, away_text, (0, record_y), record_font) # Home team (bottom right) - home_text = '' - if show_ranking: - home_text = game.get('home_record', '') - elif show_records: - home_text = game.get('home_record', '') + home_text = self._get_team_display_text( + game.get('home_abbr', ''), game.get('home_record', ''), + show_records, show_ranking + ) if home_text: home_bbox = draw.textbbox((0, 0), home_text, font=record_font) home_w = home_bbox[2] - home_bbox[0] @@ -1438,8 +1532,9 @@ def _ensure_scroll_content_for_vegas(self) -> None: # Prepare scroll content with mixed game types # Note: Using 'mixed' as game_type indicator for scroll config + rankings = self._get_rankings_cache() if self.show_ranking else None success = self._scroll_manager.prepare_and_display( - games, 'mixed', leagues, None + games, 'mixed', leagues, rankings ) if success: diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index e25ca86..5ef9931 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.2.0", + "version": "1.3.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -24,6 +24,11 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-02-14", + "version": "1.3.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-02-14", "version": "1.2.0", diff --git a/plugins/baseball-scoreboard/scroll_display.py b/plugins/baseball-scoreboard/scroll_display.py index 7f93fd9..90e782a 100644 --- a/plugins/baseball-scoreboard/scroll_display.py +++ b/plugins/baseball-scoreboard/scroll_display.py @@ -303,8 +303,7 @@ def prepare_scroll_content( games: List of game dictionaries with league info game_type: Type hint ('live', 'recent', 'upcoming', or 'mixed' for mixed types) leagues: List of leagues in order (e.g., ['mlb', 'milb', 'ncaa_baseball']) - rankings_cache: Optional team rankings cache. Kept for API compatibility with - other sports plugins but not used for baseball (baseball doesn't show rankings). + rankings_cache: Optional team rankings cache for displaying team rankings Returns: True if content was prepared successfully, False otherwise @@ -332,6 +331,10 @@ def prepare_scroll_content( # Get or create cached game renderer renderer = self._get_game_renderer() + # Pass rankings cache to renderer if available + if renderer and rankings_cache: + renderer.set_rankings_cache(rankings_cache) + # Pre-render all game cards content_items: List[Image.Image] = [] current_league = None From aaff818ba071a8ea6eb57a9d1b24a1117951810f Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 13 Feb 2026 22:17:27 -0500 Subject: [PATCH 5/6] fix(baseball): move odds fetch to update cycle, fix pick'em line and odds overlap Move blocking odds fetch from render path (_fetch_and_render_odds) to update() cycle so network I/O doesn't occur during display rendering. Fix pick'em line bug where home_spread == 0.0 was treated as missing data in both game_renderer.py and odds_manager.py. Fix odds y-position in game_renderer.py to render below the status row instead of at y=0. Co-Authored-By: Claude Opus 4.6 --- plugins.json | 2 +- plugins/baseball-scoreboard/game_renderer.py | 16 ++++++++++------ plugins/baseball-scoreboard/manager.py | 16 +++++++++++++--- plugins/baseball-scoreboard/odds_manager.py | 4 ++-- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/plugins.json b/plugins.json index cd8bcde..2c5a38b 100644 --- a/plugins.json +++ b/plugins.json @@ -296,7 +296,7 @@ "plugin_path": "plugins/baseball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-13", + "last_updated": "2026-02-14", "verified": true, "screenshot": "", "latest_version": "1.3.0" diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index a2f1564..b0bf182 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -461,10 +461,10 @@ def _draw_dynamic_odds(self, draw, odds: Dict) -> None: home_spread = home_team_odds.get('spread_odds') away_spread = away_team_odds.get('spread_odds') - # Get top-level spread as fallback + # Get top-level spread as fallback (only when individual spread is truly missing) top_level_spread = odds.get('spread') if top_level_spread is not None: - if home_spread is None or home_spread == 0.0: + if home_spread is None: home_spread = top_level_spread if away_spread is None: away_spread = -top_level_spread @@ -483,6 +483,10 @@ def _draw_dynamic_odds(self, draw, odds: Dict) -> None: favored_spread = away_spread favored_side = 'away' + # Odds row below the status/inning text row + status_bbox = draw.textbbox((0, 0), "A", font=self.fonts['time']) + odds_y = status_bbox[3] + 2 # just below the status row + # Show the negative spread on the appropriate side font = self.fonts['detail'] if favored_spread is not None: @@ -492,7 +496,7 @@ def _draw_dynamic_odds(self, draw, odds: Dict) -> None: spread_x = self.display_width - spread_width else: spread_x = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, 0), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, spread_text, (spread_x, odds_y), font, fill=(0, 255, 0)) # Show over/under on opposite side over_under = odds.get('over_under') @@ -505,10 +509,10 @@ def _draw_dynamic_odds(self, draw, odds: Dict) -> None: ou_x = self.display_width - ou_width else: ou_x = (self.display_width - ou_width) // 2 - self._draw_text_with_outline(draw, ou_text, (ou_x, 0), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, ou_text, (ou_x, odds_y), font, fill=(0, 255, 0)) - except Exception as e: - self.logger.error(f"Error drawing odds: {e}") + except Exception: + self.logger.exception("Error drawing odds") def _render_error_card(self, message: str) -> Image.Image: """Render an error message card.""" diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index d8f9069..93ef1f7 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -381,6 +381,15 @@ def update(self) -> None: game['league_config'] = league_config new_games.extend(games) + # Fetch odds for each game if enabled + if self._odds_manager: + for game in new_games: + league_config = game.get('league_config', {}) + league = game.get('league', 'mlb') + show_odds = league_config.get('show_odds', self.config.get('show_odds', False)) + if show_odds: + self._odds_manager.fetch_odds(game, league_config, 'baseball', league) + # Fetch rankings if enabled if self.show_ranking and self._rankings_manager: self._fetch_all_rankings() @@ -993,17 +1002,18 @@ def _draw_records(self, draw: ImageDraw.Draw, game: Dict, width: int, height: in self._draw_text_with_outline(draw, home_text, (width - home_w, record_y), record_font) def _fetch_and_render_odds(self, draw: ImageDraw.Draw, game: Dict, width: int, height: int): - """Fetch and render odds for a game if enabled.""" + """Render pre-fetched odds for a game if enabled. + + Odds data is populated during update() cycle — this method only renders. + """ if not self._odds_manager: return league_config = game.get('league_config', {}) - league = game.get('league', 'mlb') show_odds = league_config.get('show_odds', self.config.get('show_odds', False)) if not show_odds: return - self._odds_manager.fetch_odds(game, league_config, 'baseball', league) odds = game.get('odds') if odds: self._odds_manager.render_odds(draw, odds, width, height, self.fonts) diff --git a/plugins/baseball-scoreboard/odds_manager.py b/plugins/baseball-scoreboard/odds_manager.py index 0e920b6..55959ed 100644 --- a/plugins/baseball-scoreboard/odds_manager.py +++ b/plugins/baseball-scoreboard/odds_manager.py @@ -120,9 +120,9 @@ def render_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, # Get top-level spread as fallback top_level_spread = odds.get('spread') - # If we have a top-level spread and the individual spreads are None or 0, use the top-level + # If we have a top-level spread and the individual spreads are missing, use the top-level if top_level_spread is not None: - if home_spread is None or home_spread == 0.0: + if home_spread is None: home_spread = top_level_spread if away_spread is None: away_spread = -top_level_spread From aaa4a17ded9624317a5ede1581399953557a719f Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 13 Feb 2026 22:24:03 -0500 Subject: [PATCH 6/6] fix(baseball): thread-safe rankings, close logo file handles, Pillow compat Use atomic swap under _games_lock for _team_rankings_cache so display threads always see a consistent snapshot. Close Image.open file handles in logo fallback path and logo_manager.py by using context managers. Add RESAMPLE_FILTER compatibility shim for Pillow < 9.1 in both manager.py and logo_manager.py. Co-Authored-By: Claude Opus 4.6 --- plugins/baseball-scoreboard/logo_manager.py | 24 ++++++++++----------- plugins/baseball-scoreboard/manager.py | 23 ++++++++++++++++---- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/plugins/baseball-scoreboard/logo_manager.py b/plugins/baseball-scoreboard/logo_manager.py index dc5828b..5f49993 100644 --- a/plugins/baseball-scoreboard/logo_manager.py +++ b/plugins/baseball-scoreboard/logo_manager.py @@ -11,6 +11,12 @@ from PIL import Image +# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 +try: + RESAMPLE_FILTER = Image.Resampling.LANCZOS +except AttributeError: + RESAMPLE_FILTER = Image.LANCZOS + try: from src.logo_downloader import LogoDownloader, download_missing_logo except ImportError: @@ -105,19 +111,16 @@ def load_logo(self, team_id: str, team_abbr: str, logo_path: Path, # Only try to open the logo if the file exists if os.path.exists(actual_logo_path): - logo = Image.open(actual_logo_path) + with Image.open(actual_logo_path) as src: + logo = src.convert('RGBA') else: self.logger.error(f"Logo file still doesn't exist at {actual_logo_path} after download attempt") return None - # Ensure RGBA mode - if logo.mode != 'RGBA': - logo = logo.convert('RGBA') - # Resize to fit display (130% of display dimensions to allow extending off screen) max_width = int(self.display_width * 1.5) max_height = int(self.display_height * 1.5) - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + logo.thumbnail((max_width, max_height), RESAMPLE_FILTER) # Cache the logo self._logo_cache[team_abbr] = logo @@ -149,19 +152,16 @@ def load_milb_logo(self, team_abbr: str, logo_dir: Path) -> Optional[Image.Image logo_path = logo_dir / f"{team_abbr}.png" if logo_path.exists(): - logo = Image.open(logo_path) + with Image.open(logo_path) as src: + logo = src.convert('RGBA') else: self.logger.warning(f"MiLB logo not found for {team_abbr} at {logo_path}") return None - # Ensure RGBA mode - if logo.mode != 'RGBA': - logo = logo.convert('RGBA') - # Resize to fit display (130% of display dimensions) max_width = int(self.display_width * 1.5) max_height = int(self.display_height * 1.5) - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + logo.thumbnail((max_width, max_height), RESAMPLE_FILTER) # Cache the logo self._logo_cache[team_abbr] = logo diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 93ef1f7..b5f1c6b 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -27,6 +27,12 @@ import requests from PIL import Image, ImageDraw, ImageFont +# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 +try: + RESAMPLE_FILTER = Image.Resampling.LANCZOS +except AttributeError: + RESAMPLE_FILTER = Image.LANCZOS + from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode # Import baseball base classes from LEDMatrix @@ -423,7 +429,10 @@ def sort_key(game): self.current_games.sort(key=sort_key) def _fetch_all_rankings(self): - """Fetch team rankings for all enabled leagues that support rankings.""" + """Fetch team rankings for all enabled leagues that support rankings. + + Uses atomic swap to avoid concurrent read/write issues with display threads. + """ if not self._rankings_manager: return @@ -433,6 +442,7 @@ def _fetch_all_rankings(self): 'ncaa_baseball': ('baseball', 'college-baseball'), } + new_cache: Dict[str, int] = {} for league_key, league_config in self.leagues.items(): if not league_config.get('enabled', False): continue @@ -442,7 +452,11 @@ def _fetch_all_rankings(self): sport, league_id = league_mappings[league_key] rankings = self._rankings_manager.fetch_rankings(sport, league_id, league_key) if rankings: - self._team_rankings_cache.update(rankings) + new_cache.update(rankings) + + # Atomic swap under lock so display threads see a consistent snapshot + with self._games_lock: + self._team_rankings_cache = new_cache def _get_rankings_cache(self) -> Dict[str, int]: """Get the combined team rankings cache.""" @@ -898,10 +912,11 @@ def _load_team_logo(self, team_abbrev: str, league: str, game: Dict = None) -> O if not found_path: return None - logo = Image.open(found_path).convert('RGBA') + with Image.open(found_path) as src: + logo = src.convert('RGBA') max_width = int(self.display_manager.matrix.width * 1.5) max_height = int(self.display_manager.matrix.height * 1.5) - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + logo.thumbnail((max_width, max_height), RESAMPLE_FILTER) return logo