diff --git a/plugins.json b/plugins.json index 5f9927e..2c5a38b 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-14", "verified": true, "screenshot": "", - "latest_version": "1.0.5" + "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 cbcd6a6..b0bf182 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -2,13 +2,15 @@ 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 +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 @@ -48,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() @@ -72,8 +77,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 +94,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}") @@ -123,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. @@ -145,58 +160,135 @@ 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', ''), 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") 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" + 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}" + + 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) + + # Odds + if game.get('odds'): + self._draw_dynamic_odds(draw, game['odds']) - # Composite and convert to RGB main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -207,51 +299,42 @@ 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', ''), 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") 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) + # 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) - # 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) + + # Odds + if game.get('odds'): + self._draw_dynamic_odds(draw, game['odds']) - # Composite and convert to RGB main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -262,49 +345,60 @@ 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', ''), 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") 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: + 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_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) + + # 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") @@ -312,6 +406,114 @@ 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 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)) + + 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 = self.display_height - record_height + + # 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_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.""" + 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 (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: + 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' + + # 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: + 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, odds_y), 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, odds_y), font, fill=(0, 255, 0)) + + except Exception: + self.logger.exception("Error drawing odds") + 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/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 6be95f6..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 @@ -54,6 +60,25 @@ except ImportError: ScrollDisplayManager = None SCROLL_AVAILABLE = False + +# Import odds manager +try: + 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__) @@ -190,6 +215,34 @@ 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}") + + # 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 @@ -334,6 +387,19 @@ 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() + # Update shared state under lock (protected by lock for thread safety) with self._games_lock: self.current_games = new_games @@ -349,13 +415,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 @@ -367,6 +428,40 @@ 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. + + Uses atomic swap to avoid concurrent read/write issues with display threads. + """ + if not self._rankings_manager: + return + + # ESPN league identifiers for rankings API + league_mappings = { + 'mlb': ('baseball', 'mlb'), + '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 + 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: + 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.""" + 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. @@ -395,31 +490,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 +587,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 +605,124 @@ 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 = 'end' + elif 'mid' in status_detail or 'mid' in status_short: + 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 None + outs = situation.get('outs', 0) if situation else 0 + + 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): + 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, + 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 +731,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 +792,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 +806,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 +828,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 +849,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, 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 + # 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,43 +867,61 @@ 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_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') + + 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 - + 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 +937,394 @@ 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, game) + away_logo = self._load_team_logo(game.get('away_abbr', ''), league, game) + + 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 _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', {}) + 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 = 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_text, (width - home_w, record_y), record_font) + + def _fetch_and_render_odds(self, draw: ImageDraw.Draw, game: Dict, width: int, height: int): + """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', {}) + show_odds = league_config.get('show_odds', self.config.get('show_odds', False)) + if not show_odds: + return + + 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 + 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" + 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}" + + 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) + + # 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() + 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) + + # 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() + 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) + + # 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() + 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 +1377,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 +1439,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 +1465,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,18 +1548,18 @@ 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 # 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 94d4623..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.0.5", + "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,21 @@ "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", + "ledmatrix_min": "2.0.0" + }, + { + "released": "2026-02-13", + "version": "1.1.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-02-12", "version": "1.0.5", @@ -45,7 +60,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-02-12", + "last_updated": "2026-02-14", "stars": 0, "downloads": 0, "verified": true, 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 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..90e782a 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 @@ -304,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 @@ -333,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