From ba26001370eb8fa631341c362a13e5ed29b15cf9 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 13 Feb 2026 21:13:31 -0500 Subject: [PATCH 01/15] feat(baseball): add full scorebug rendering with baseball-specific elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate v2.5 baseball scorebug rendering into manager.py with dedicated display methods for live, recent, and upcoming games. Live games now show inning indicator (▲/▼), base diamonds, outs circles, balls-strikes count, and team:score layout. Standardize data extraction to flat dict format matching other sports plugins. Update scroll mode game_renderer.py with matching baseball elements. Remove unused scorebug_renderer.py. Co-Authored-By: Claude Opus 4.6 --- plugins.json | 4 +- plugins/baseball-scoreboard/game_renderer.py | 294 +++++--- plugins/baseball-scoreboard/manager.py | 693 +++++++++++++----- plugins/baseball-scoreboard/manifest.json | 9 +- .../baseball-scoreboard/scorebug_renderer.py | 693 ------------------ plugins/baseball-scoreboard/scroll_display.py | 7 +- 6 files changed, 723 insertions(+), 977 deletions(-) delete mode 100644 plugins/baseball-scoreboard/scorebug_renderer.py diff --git a/plugins.json b/plugins.json index 5f9927e..f8fe62a 100644 --- a/plugins.json +++ b/plugins.json @@ -296,10 +296,10 @@ "plugin_path": "plugins/baseball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-12", + "last_updated": "2026-02-13", "verified": true, "screenshot": "", - "latest_version": "1.0.5" + "latest_version": "1.1.0" }, { "id": "soccer-scoreboard", diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index cbcd6a6..7d54bc2 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -2,7 +2,7 @@ Game Card Renderer for Baseball Scoreboard Plugin Renders individual baseball game cards as PIL Images for use in scroll mode. -Adapted from scorebug_renderer.py but returns images instead of updating display directly. +Returns images instead of updating display directly. """ import logging @@ -145,58 +145,127 @@ def render_game_card(self, game: Dict, game_type: str) -> Image.Image: return self._render_error_card("Unknown type") def _render_live_game(self, game: Dict) -> Image.Image: - """Render a live baseball game card.""" + """Render a live baseball game card with full scorebug elements.""" try: - # Create main image with transparency main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) + draw = ImageDraw.Draw(overlay) - # Get team info and league - home_team = game.get('home_team', {}) - away_team = game.get('away_team', {}) league = game.get('league', 'mlb') - - # Load team logos - home_logo = self._load_and_resize_logo(league, home_team.get('abbrev', '')) - away_logo = self._load_and_resize_logo(league, away_team.get('abbrev', '')) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) if not home_logo or not away_logo: return self._render_error_card("Logo Error") center_y = self.display_height // 2 - # Draw logos - home_x = self.display_width - home_logo.width + 10 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw inning and status - inning_info = game.get('inning_info', {}) - inning = inning_info.get('inning', 1) - inning_half = inning_info.get('half', 'top') - inning_symbol = "▲" if inning_half == 'top' else "▼" - inning_text = f"{inning_symbol}{inning}" - - status_width = draw_overlay.textlength(inning_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 1 - self._draw_text_with_outline(draw_overlay, inning_text, (status_x, status_y), self.fonts['time']) - - # Draw scores - home_score = str(home_team.get("score", "0")) - away_score = str(away_team.get("score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) + # Logos + main_img.paste(home_logo, (self.display_width - home_logo.width + 10, center_y - home_logo.height // 2), home_logo) + main_img.paste(away_logo, (-10, center_y - away_logo.height // 2), away_logo) + + # Inning indicator (top center) + inning_half = game.get('inning_half', 'top') + inning_num = game.get('inning', 1) + if game.get('is_final'): + inning_text = "FINAL" + else: + symbol = "▲" if inning_half == 'top' else "▼" + inning_text = f"{symbol}{inning_num}" + + inning_font = self.fonts['time'] + inning_bbox = draw.textbbox((0, 0), inning_text, font=inning_font) + inning_width = inning_bbox[2] - inning_bbox[0] + inning_x = (self.display_width - inning_width) // 2 + inning_y = 1 + self._draw_text_with_outline(draw, inning_text, (inning_x, inning_y), inning_font) + + # Bases diamond + Outs circles + bases_occupied = game.get('bases_occupied', [False, False, False]) + outs = game.get('outs', 0) + + base_diamond_size = 7 + out_circle_diameter = 3 + out_vertical_spacing = 2 + spacing_between_bases_outs = 3 + base_vert_spacing = 1 + base_horiz_spacing = 1 + + base_cluster_height = base_diamond_size + base_vert_spacing + base_diamond_size + base_cluster_width = base_diamond_size + base_horiz_spacing + base_diamond_size + + overall_start_y = inning_bbox[3] + 1 + bases_origin_x = (self.display_width - base_cluster_width) // 2 + + # Outs column position + out_cluster_height = 3 * out_circle_diameter + 2 * out_vertical_spacing + if inning_half == 'top': + outs_column_x = bases_origin_x - spacing_between_bases_outs - out_circle_diameter + else: + outs_column_x = bases_origin_x + base_cluster_width + spacing_between_bases_outs + outs_column_start_y = overall_start_y + (base_cluster_height // 2) - (out_cluster_height // 2) + + # Draw bases as diamond polygons + h_d = base_diamond_size // 2 + base_fill = (255, 255, 255) + base_outline = (255, 255, 255) + + # 2nd base (top center) + c2x = bases_origin_x + base_cluster_width // 2 + c2y = overall_start_y + h_d + poly2 = [(c2x, overall_start_y), (c2x + h_d, c2y), (c2x, c2y + h_d), (c2x - h_d, c2y)] + draw.polygon(poly2, fill=base_fill if bases_occupied[1] else None, outline=base_outline) + + base_bottom_y = c2y + h_d + + # 3rd base (bottom left) + c3x = bases_origin_x + h_d + c3y = base_bottom_y + base_vert_spacing + h_d + poly3 = [(c3x, base_bottom_y + base_vert_spacing), (c3x + h_d, c3y), (c3x, c3y + h_d), (c3x - h_d, c3y)] + draw.polygon(poly3, fill=base_fill if bases_occupied[2] else None, outline=base_outline) + + # 1st base (bottom right) + c1x = bases_origin_x + base_cluster_width - h_d + c1y = base_bottom_y + base_vert_spacing + h_d + poly1 = [(c1x, base_bottom_y + base_vert_spacing), (c1x + h_d, c1y), (c1x, c1y + h_d), (c1x - h_d, c1y)] + draw.polygon(poly1, fill=base_fill if bases_occupied[0] else None, outline=base_outline) + + # Outs circles + for i in range(3): + cx = outs_column_x + cy = outs_column_start_y + i * (out_circle_diameter + out_vertical_spacing) + coords = [cx, cy, cx + out_circle_diameter, cy + out_circle_diameter] + if i < outs: + draw.ellipse(coords, fill=(255, 255, 255)) + else: + draw.ellipse(coords, outline=(100, 100, 100)) + + # Balls-strikes count (below bases) + balls = game.get('balls', 0) + strikes = game.get('strikes', 0) + count_text = f"{balls}-{strikes}" + count_font = self.fonts['detail'] + count_width = draw.textlength(count_text, font=count_font) + count_y = overall_start_y + base_cluster_height + 2 + count_x = bases_origin_x + (base_cluster_width - count_width) // 2 + self._draw_text_with_outline(draw, count_text, (int(count_x), count_y), count_font) + + # Team:Score at bottom corners + score_font = self.fonts['score'] + away_text = f"{game.get('away_abbr', '')}:{game.get('away_score', '0')}" + home_text = f"{game.get('home_abbr', '')}:{game.get('home_score', '0')}" + try: + font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1] + except AttributeError: + font_height = 8 + score_y = self.display_height - font_height - 2 + self._draw_text_with_outline(draw, away_text, (2, score_y), score_font) + try: + home_w = draw.textbbox((0, 0), home_text, font=score_font)[2] + except AttributeError: + home_w = len(home_text) * 8 + self._draw_text_with_outline(draw, home_text, (self.display_width - home_w - 2, score_y), score_font) - # Composite and convert to RGB main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -207,51 +276,38 @@ def _render_live_game(self, game: Dict) -> Image.Image: def _render_recent_game(self, game: Dict) -> Image.Image: """Render a recent baseball game card.""" try: - # Create main image with transparency main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) + draw = ImageDraw.Draw(overlay) - # Get team info and league - home_team = game.get('home_team', {}) - away_team = game.get('away_team', {}) league = game.get('league', 'mlb') - - # Load team logos - home_logo = self._load_and_resize_logo(league, home_team.get('abbrev', '')) - away_logo = self._load_and_resize_logo(league, away_team.get('abbrev', '')) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) if not home_logo or not away_logo: return self._render_error_card("Logo Error") center_y = self.display_height // 2 - # Draw logos - home_x = self.display_width - home_logo.width + 10 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) + # Logos (tighter fit for recent) + main_img.paste(home_logo, (self.display_width - home_logo.width + 2, center_y - home_logo.height // 2), home_logo) + main_img.paste(away_logo, (-2, center_y - away_logo.height // 2), away_logo) - away_x = -10 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw "Final" status + # "Final" (top center) status_text = "Final" - status_width = draw_overlay.textlength(status_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 1 - self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), self.fonts['time']) - - # Draw scores - home_score = str(home_team.get("score", "0")) - away_score = str(away_team.get("score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) + status_width = draw.textlength(status_text, font=self.fonts['time']) + self._draw_text_with_outline(draw, status_text, ((self.display_width - status_width) // 2, 1), self.fonts['time']) + + # Score (centered) + score_text = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" + score_width = draw.textlength(score_text, font=self.fonts['score']) score_x = (self.display_width - score_width) // 2 - score_y = (self.display_height // 2) - 3 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) + score_y = self.display_height - 14 + self._draw_text_with_outline(draw, score_text, (score_x, score_y), self.fonts['score'], fill=(255, 200, 0)) + + # Records at bottom corners + self._draw_records(draw, game) - # Composite and convert to RGB main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -262,49 +318,58 @@ def _render_recent_game(self, game: Dict) -> Image.Image: def _render_upcoming_game(self, game: Dict) -> Image.Image: """Render an upcoming baseball game card.""" try: - # Create main image with transparency main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) + draw = ImageDraw.Draw(overlay) - # Get team info and league - home_team = game.get('home_team', {}) - away_team = game.get('away_team', {}) league = game.get('league', 'mlb') - - # Load team logos - home_logo = self._load_and_resize_logo(league, home_team.get('abbrev', '')) - away_logo = self._load_and_resize_logo(league, away_team.get('abbrev', '')) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) if not home_logo or not away_logo: return self._render_error_card("Logo Error") center_y = self.display_height // 2 - # Draw logos - home_x = self.display_width - home_logo.width + 10 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw game time - game_time = game.get('start_time_short', 'TBD') - time_width = draw_overlay.textlength(game_time, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 - time_y = 1 - self._draw_text_with_outline(draw_overlay, game_time, (time_x, time_y), self.fonts['time']) - - # Draw "vs" in center - vs_text = "VS" - vs_width = draw_overlay.textlength(vs_text, font=self.fonts['score']) - vs_x = (self.display_width - vs_width) // 2 - vs_y = (self.display_height // 2) - 3 - self._draw_text_with_outline(draw_overlay, vs_text, (vs_x, vs_y), self.fonts['score']) - - # Composite and convert to RGB + # Logos (tighter fit) + main_img.paste(home_logo, (self.display_width - home_logo.width + 2, center_y - home_logo.height // 2), home_logo) + main_img.paste(away_logo, (-2, center_y - away_logo.height // 2), away_logo) + + # "Next Game" (top center) + status_font = self.fonts['status'] if self.display_width <= 128 else self.fonts['time'] + status_text = "Next Game" + status_width = draw.textlength(status_text, font=status_font) + self._draw_text_with_outline(draw, status_text, ((self.display_width - status_width) // 2, 1), status_font) + + # Game time/date from start_time + start_time = game.get('start_time', '') + game_date = '' + game_time = '' + if start_time: + try: + from datetime import datetime + import pytz + dt = datetime.fromisoformat(start_time.replace('Z', '+00:00')) + local_tz = pytz.timezone(self.config.get('timezone', 'US/Eastern')) + dt_local = dt.astimezone(local_tz) + game_date = dt_local.strftime('%b %d') + game_time = dt_local.strftime('%-I:%M %p') + except (ValueError, AttributeError, ImportError): + game_time = start_time[:10] if len(start_time) > 10 else start_time + + time_font = self.fonts['time'] + if game_date: + date_width = draw.textlength(game_date, font=time_font) + draw_y = center_y - 7 + self._draw_text_with_outline(draw, game_date, ((self.display_width - date_width) // 2, draw_y), time_font) + if game_time: + time_width = draw.textlength(game_time, font=time_font) + draw_y = center_y + 2 + self._draw_text_with_outline(draw, game_time, ((self.display_width - time_width) // 2, draw_y), time_font) + + # Records at bottom corners + self._draw_records(draw, game) + main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -312,6 +377,25 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: self.logger.exception("Error rendering upcoming game") return self._render_error_card("Display error") + def _draw_records(self, draw, game: Dict): + """Draw team records at bottom corners.""" + away_record = game.get('away_record', '') + home_record = game.get('home_record', '') + if not away_record and not home_record: + return + + record_font = self.fonts['detail'] + record_bbox = draw.textbbox((0, 0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height + + if away_record: + self._draw_text_with_outline(draw, away_record, (0, record_y), record_font) + if home_record: + home_bbox = draw.textbbox((0, 0), home_record, font=record_font) + home_w = home_bbox[2] - home_bbox[0] + self._draw_text_with_outline(draw, home_record, (self.display_width - home_w, record_y), record_font) + def _render_error_card(self, message: str) -> Image.Image: """Render an error message card.""" img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 6be95f6..59e333e 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -349,13 +349,8 @@ def update(self) -> None: def _sort_games(self): """Sort games by priority and favorites.""" def sort_key(game): - league_key = game.get('league') - league_config = game.get('league_config', {}) - status = game.get('status', {}) - # Priority 1: Live games - is_live = status.get('state') == 'in' - live_score = 0 if is_live else 1 + live_score = 0 if game.get('is_live') else 1 # Priority 2: Favorite teams favorite_score = 0 if self._is_favorite_game(game) else 1 @@ -395,31 +390,39 @@ def _fetch_via_data_manager(self, league_key: str, league_config: Dict) -> List[ return self._fetch_league_data_sync(league_key, league_config) def _convert_milb_game(self, milb_data: Dict) -> Dict: - """Convert data_manager MiLB format to the game dict format used by display methods.""" + """Convert data_manager MiLB format to flat game dict format.""" + status_state = milb_data.get('status_state', 'pre') + home_abbr = milb_data.get('home_team', '') + away_abbr = milb_data.get('away_team', '') + logo_dir = self.leagues.get('milb', {}).get('logo_dir', 'assets/sports/milb_logos') return { 'league': 'milb', - 'game_id': milb_data.get('id'), - 'home_team': { - 'name': '', - 'abbrev': milb_data.get('home_team', ''), - 'score': milb_data.get('home_score', 0), - 'logo': None - }, - 'away_team': { - 'name': '', - 'abbrev': milb_data.get('away_team', ''), - 'score': milb_data.get('away_score', 0), - 'logo': None - }, - 'status': { - 'state': milb_data.get('status_state', 'pre'), - 'detail': milb_data.get('detailed_state', ''), - 'short_detail': milb_data.get('detailed_state', ''), - 'period': milb_data.get('inning', 0), - 'display_clock': '' - }, + 'id': milb_data.get('id'), + 'home_abbr': home_abbr, + 'away_abbr': away_abbr, + 'home_id': milb_data.get('home_id', ''), + 'away_id': milb_data.get('away_id', ''), + 'home_score': milb_data.get('home_score', '0'), + 'away_score': milb_data.get('away_score', '0'), + 'home_record': milb_data.get('home_record', ''), + 'away_record': milb_data.get('away_record', ''), + 'home_logo_path': Path(logo_dir) / f"{home_abbr}.png", + 'away_logo_path': Path(logo_dir) / f"{away_abbr}.png", + 'home_logo_url': None, + 'away_logo_url': None, + 'status_state': status_state, + 'status_text': milb_data.get('detailed_state', ''), + 'is_live': status_state == 'in', + 'is_final': status_state == 'post', + 'is_upcoming': status_state == 'pre', 'start_time': milb_data.get('start_time', ''), - 'venue': '' + 'venue': '', + 'inning': milb_data.get('inning', 1), + 'inning_half': milb_data.get('inning_half', 'top'), + 'balls': milb_data.get('balls', 0), + 'strikes': milb_data.get('strikes', 0), + 'outs': milb_data.get('outs', 0), + 'bases_occupied': milb_data.get('bases_occupied', [False, False, False]), } def _fetch_league_data_sync(self, league_key: str, league_config: Dict) -> List[Dict]: @@ -484,11 +487,13 @@ def _process_api_response(self, data: Dict, league_key: str, league_config: Dict return games def _extract_game_info(self, event: Dict, league_key: str, league_config: Dict) -> Optional[Dict]: - """Extract game information from ESPN event.""" + """Extract game information from ESPN event into flat dict format with baseball fields.""" try: competition = event.get('competitions', [{}])[0] status = competition.get('status', {}) competitors = competition.get('competitors', []) + situation = competition.get('situation') + game_date_str = event.get('date', '') if len(competitors) < 2: return None @@ -500,34 +505,122 @@ def _extract_game_info(self, event: Dict, league_key: str, league_config: Dict) if not home_team or not away_team: return None - # Extract game details + try: + home_abbr = home_team['team']['abbreviation'] + except KeyError: + home_abbr = home_team.get('team', {}).get('name', 'UNK')[:3] + try: + away_abbr = away_team['team']['abbreviation'] + except KeyError: + away_abbr = away_team.get('team', {}).get('name', 'UNK')[:3] + + status_state = status.get('type', {}).get('state', 'unknown').lower() + + # Get records + home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else '' + away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else '' + if home_record in {'0-0', '0-0-0'}: + home_record = '' + if away_record in {'0-0', '0-0-0'}: + away_record = '' + + # Determine logo directory based on league + if league_key == 'ncaa_baseball': + logo_dir = 'assets/sports/ncaa_logos' + elif league_key == 'milb': + logo_dir = league_config.get('logo_dir', 'assets/sports/milb_logos') + else: + logo_dir = 'assets/sports/mlb_logos' + game = { 'league': league_key, 'league_config': league_config, - 'game_id': event.get('id'), - 'home_team': { - 'name': home_team.get('team', {}).get('displayName', 'Unknown'), - 'abbrev': home_team.get('team', {}).get('abbreviation', 'UNK'), - 'score': int(home_team.get('score', 0)), - 'logo': home_team.get('team', {}).get('logo') - }, - 'away_team': { - 'name': away_team.get('team', {}).get('displayName', 'Unknown'), - 'abbrev': away_team.get('team', {}).get('abbreviation', 'UNK'), - 'score': int(away_team.get('score', 0)), - 'logo': away_team.get('team', {}).get('logo') - }, - 'status': { - 'state': status.get('type', {}).get('state', 'unknown'), - 'detail': status.get('type', {}).get('detail', ''), - 'short_detail': status.get('type', {}).get('shortDetail', ''), - 'period': status.get('period', 0), - 'display_clock': status.get('displayClock', '') - }, - 'start_time': event.get('date', ''), - 'venue': competition.get('venue', {}).get('fullName', 'Unknown Venue') + 'id': event.get('id'), + 'home_abbr': home_abbr, + 'away_abbr': away_abbr, + 'home_id': home_team.get('id'), + 'away_id': away_team.get('id'), + 'home_score': home_team.get('score', '0'), + 'away_score': away_team.get('score', '0'), + 'home_record': home_record, + 'away_record': away_record, + 'home_logo_path': Path(logo_dir) / f"{home_abbr}.png", + 'away_logo_path': Path(logo_dir) / f"{away_abbr}.png", + 'home_logo_url': home_team.get('team', {}).get('logo'), + 'away_logo_url': away_team.get('team', {}).get('logo'), + 'status_state': status_state, + 'status_text': status.get('type', {}).get('shortDetail', ''), + 'is_live': status_state == 'in', + 'is_final': status_state == 'post', + 'is_upcoming': status_state == 'pre', + 'start_time': game_date_str, + 'venue': competition.get('venue', {}).get('fullName', ''), } + # Extract baseball-specific details for live games + if status_state == 'in': + inning = status.get('period', 1) + status_detail = status.get('type', {}).get('detail', '').lower() + status_short = status.get('type', {}).get('shortDetail', '').lower() + + # Determine inning half from status text + inning_half = 'top' + if 'end' in status_detail or 'end' in status_short: + inning_half = 'top' + inning = status.get('period', 1) + 1 + elif 'mid' in status_detail or 'mid' in status_short: + inning_half = 'bottom' + elif 'bottom' in status_detail or 'bot' in status_detail or 'bottom' in status_short or 'bot' in status_short: + inning_half = 'bottom' + elif 'top' in status_detail or 'top' in status_short: + inning_half = 'top' + + # Get count and bases from situation + count = situation.get('count', {}) if situation else {} + balls = count.get('balls', 0) + strikes = count.get('strikes', 0) + outs = situation.get('outs', 0) if situation else 0 + + # Try alternative locations for count data + if balls == 0 and strikes == 0 and situation: + if 'summary' in situation: + try: + balls, strikes = map(int, situation['summary'].split('-')) + except (ValueError, AttributeError): + pass + else: + balls = situation.get('balls', 0) + strikes = situation.get('strikes', 0) + + bases_occupied = [ + situation.get('onFirst', False) if situation else False, + situation.get('onSecond', False) if situation else False, + situation.get('onThird', False) if situation else False, + ] + + game.update({ + 'inning': inning, + 'inning_half': inning_half, + 'balls': balls, + 'strikes': strikes, + 'outs': outs, + 'bases_occupied': bases_occupied, + }) + else: + game.update({ + 'inning': 1, + 'inning_half': 'top', + 'balls': 0, + 'strikes': 0, + 'outs': 0, + 'bases_occupied': [False, False, False], + }) + + # Get series summary if available + series = competition.get('series') + if series: + game['series_summary'] = series.get('summary', '') + return game except Exception as e: @@ -536,17 +629,13 @@ def _extract_game_info(self, event: Dict, league_key: str, league_config: Dict) def _is_favorite_game(self, game: Dict) -> bool: """Check if game involves a favorite team.""" - league = game.get('league') league_config = game.get('league_config', {}) favorites = league_config.get('favorite_teams', []) if not favorites: return False - home_abbrev = game.get('home_team', {}).get('abbrev') - away_abbrev = game.get('away_team', {}).get('abbrev') - - return home_abbrev in favorites or away_abbrev in favorites + return game.get('home_abbr', '') in favorites or game.get('away_abbr', '') in favorites def display(self, display_mode: str = None, force_clear: bool = False) -> bool: """ @@ -601,8 +690,7 @@ def _filter_games_by_mode(self, mode: str) -> List[Dict]: for game in games_copy: league_key = game.get('league') league_config = game.get('league_config', {}) - status = game.get('status', {}) - state = status.get('state') + status_state = game.get('status_state', '') # Check if this mode is enabled for this league display_modes = league_config.get('display_modes', {}) @@ -616,21 +704,19 @@ def _filter_games_by_mode(self, mode: str) -> List[Dict]: continue # Filter by game state and per-league limits - if mode == 'baseball_live' and state == 'in': + if mode == 'baseball_live' and status_state == 'in': filtered.append(game) - elif mode == 'baseball_recent' and state == 'post': - # Check recent games limit for this league + elif mode == 'baseball_recent' and status_state == 'post': recent_limit = league_config.get('recent_games_to_show', 5) - recent_count = len([g for g in filtered if g.get('league') == league_key and g.get('status', {}).get('state') == 'post']) + recent_count = len([g for g in filtered if g.get('league') == league_key and g.get('is_final')]) if recent_count >= recent_limit: continue filtered.append(game) - elif mode == 'baseball_upcoming' and state == 'pre': - # Check upcoming games limit for this league + elif mode == 'baseball_upcoming' and status_state == 'pre': upcoming_limit = league_config.get('upcoming_games_to_show', 10) - upcoming_count = len([g for g in filtered if g.get('league') == league_key and g.get('status', {}).get('state') == 'pre']) + upcoming_count = len([g for g in filtered if g.get('league') == league_key and g.get('is_upcoming')]) if upcoming_count >= upcoming_limit: continue filtered.append(game) @@ -640,12 +726,12 @@ def _filter_games_by_mode(self, mode: str) -> List[Dict]: def _has_live_games(self) -> bool: """Check if there are any live games available.""" with self._games_lock: - return any(game.get('status', {}).get('state') == 'in' for game in self.current_games) + return any(game.get('is_live') for game in self.current_games) def _has_recent_games(self) -> bool: """Check if there are any recent games available.""" with self._games_lock: - return any(game.get('status', {}).get('state') == 'post' for game in self.current_games) + return any(game.get('is_final') for game in self.current_games) def has_live_content(self) -> bool: """ @@ -661,13 +747,16 @@ def get_live_modes(self) -> list: """ return ['baseball_live'] - def _load_team_logo(self, team: Dict, league: str) -> Optional[Image.Image]: - """Load and resize team logo - matching football plugin logic.""" + def _load_team_logo(self, team_abbrev: str, league: str) -> Optional[Image.Image]: + """Load and resize team logo.""" try: + if not team_abbrev: + return None + # Get logo directory from league configuration league_config = self.leagues.get(league, {}) logo_dir = league_config.get('logo_dir', 'assets/sports/mlb_logos') - + # Convert relative path to absolute path by finding LEDMatrix project root if not os.path.isabs(logo_dir): current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -676,21 +765,17 @@ def _load_team_logo(self, team: Dict, league: str) -> Optional[Image.Image]: if os.path.exists(os.path.join(parent, 'assets', 'sports')): ledmatrix_root = parent break - + if ledmatrix_root: logo_dir = os.path.join(ledmatrix_root, logo_dir) else: logo_dir = os.path.abspath(logo_dir) - - team_abbrev = team.get('abbrev', '') - if not team_abbrev: - return None - + # Try different case variations and extensions logo_extensions = ['.png', '.jpg', '.jpeg'] logo_path = None abbrev_variations = [team_abbrev.upper(), team_abbrev.lower(), team_abbrev] - + for abbrev in abbrev_variations: for ext in logo_extensions: potential_path = os.path.join(logo_dir, f"{abbrev}{ext}") @@ -699,20 +784,20 @@ def _load_team_logo(self, team: Dict, league: str) -> Optional[Image.Image]: break if logo_path: break - + if not logo_path: return None - + # Load and resize logo (matching original managers) logo = Image.open(logo_path).convert('RGBA') max_width = int(self.display_manager.matrix.width * 1.5) max_height = int(self.display_manager.matrix.height * 1.5) logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - + return logo - + except Exception as e: - self.logger.debug(f"Could not load logo for {team.get('abbrev', 'unknown')}: {e}") + self.logger.debug(f"Could not load logo for {team_abbrev}: {e}") return None def _draw_text_with_outline(self, draw: ImageDraw.Draw, text: str, position: tuple, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): @@ -728,83 +813,353 @@ def _draw_text_with_outline(self, draw: ImageDraw.Draw, text: str, position: tup self.logger.error(f"Error drawing text with outline: {e}") def _display_game(self, game: Dict, mode: str): - """Display a single baseball game with proper scoreboard layout.""" + """Display a single baseball game, routing to the appropriate renderer.""" try: - matrix_width = self.display_manager.matrix.width - matrix_height = self.display_manager.matrix.height - - # Create image with transparency support - main_img = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) - - # Get team info - home_team = game.get('home_team', {}) - away_team = game.get('away_team', {}) - status = game.get('status', {}) - - # Load team logos - home_logo = self._load_team_logo(home_team, game.get('league', '')) - away_logo = self._load_team_logo(away_team, game.get('league', '')) - - if home_logo and away_logo: - # Draw logos with layout offset support - center_y = matrix_height // 2 - home_x = matrix_width - home_logo.width + 10 + self._get_layout_offset('home_logo', 'x_offset') - home_y = center_y - (home_logo.height // 2) + self._get_layout_offset('home_logo', 'y_offset') - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 + self._get_layout_offset('away_logo', 'x_offset') - away_y = center_y - (away_logo.height // 2) + self._get_layout_offset('away_logo', 'y_offset') - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw scores (centered) with layout offset support - home_score = str(home_team.get('score', 0)) - away_score = str(away_team.get('score', 0)) - score_text = f"{away_score}-{home_score}" - - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (matrix_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') - score_y = (matrix_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score'], fill=(255, 200, 0)) - - # Inning/Status (top center) with layout offset support - if status.get('state') == 'post': - status_text = "FINAL" - elif status.get('state') == 'pre': - status_text = "UPCOMING" - else: - # Live game - show inning - status_text = status.get('detail', status.get('short_detail', '')) - - status_width = draw_overlay.textlength(status_text, font=self.fonts['time']) - status_x = (matrix_width - status_width) // 2 + self._get_layout_offset('status', 'x_offset') - status_y = 1 + self._get_layout_offset('status', 'y_offset') - self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), self.fonts['time'], fill=(0, 255, 0)) - - # Composite and display - final_img = Image.alpha_composite(main_img, overlay) - self.display_manager.image = final_img.convert('RGB').copy() + if mode == 'baseball_live': + self._display_live_game(game) + elif mode == 'baseball_recent': + self._display_recent_game(game) + elif mode == 'baseball_upcoming': + self._display_upcoming_game(game) else: - # Text fallback if logos fail - img = Image.new('RGB', (matrix_width, matrix_height), (0, 0, 0)) - draw = ImageDraw.Draw(img) - - home_abbrev = home_team.get('abbrev', 'HOME') - away_abbrev = away_team.get('abbrev', 'AWAY') - - draw.text((5, 5), f"{away_abbrev} @ {home_abbrev}", fill=(255, 255, 255)) - draw.text((5, 15), f"{away_team.get('score', 0)} - {home_team.get('score', 0)}", fill=(255, 200, 0)) - draw.text((5, 25), status.get('short_detail', ''), fill=(0, 255, 0)) - - self.display_manager.image = img.copy() - - self.display_manager.update_display() - + self._display_recent_game(game) except Exception as e: - self.logger.error(f"Error displaying game: {e}") + self.logger.error(f"Error displaying game: {e}", exc_info=True) self._display_error("Display error") + def _paste_logos(self, main_img: Image.Image, game: Dict, inward_offset: int = 10): + """Load and paste team logos onto the image. Returns (home_logo, away_logo) or (None, None).""" + league = game.get('league', '') + home_logo = self._load_team_logo(game.get('home_abbr', ''), league) + away_logo = self._load_team_logo(game.get('away_abbr', ''), league) + + if not home_logo or not away_logo: + return None, None + + center_y = main_img.height // 2 + home_x = main_img.width - home_logo.width + inward_offset + self._get_layout_offset('home_logo', 'x_offset') + home_y = center_y - (home_logo.height // 2) + self._get_layout_offset('home_logo', 'y_offset') + main_img.paste(home_logo, (home_x, home_y), home_logo) + + away_x = -inward_offset + self._get_layout_offset('away_logo', 'x_offset') + away_y = center_y - (away_logo.height // 2) + self._get_layout_offset('away_logo', 'y_offset') + main_img.paste(away_logo, (away_x, away_y), away_logo) + + return home_logo, away_logo + + def _draw_records(self, draw: ImageDraw.Draw, game: Dict, width: int, height: int): + """Draw team records or rankings at bottom corners if enabled.""" + league_config = game.get('league_config', {}) + show_records = league_config.get('show_records', self.show_records) + show_ranking = league_config.get('show_ranking', self.show_ranking) + + if not show_records and not show_ranking: + return + + record_font = self.fonts['detail'] + record_bbox = draw.textbbox((0, 0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = height - record_height + + # Away team (bottom left) + away_text = '' + if show_ranking: + away_text = game.get('away_record', '') # rankings would replace this if available + elif show_records: + away_text = game.get('away_record', '') + if away_text: + self._draw_text_with_outline(draw, away_text, (0, record_y), record_font) + + # Home team (bottom right) + home_text = '' + if show_ranking: + home_text = game.get('home_record', '') + elif show_records: + home_text = game.get('home_record', '') + if home_text: + home_bbox = draw.textbbox((0, 0), home_text, font=record_font) + home_w = home_bbox[2] - home_bbox[0] + self._draw_text_with_outline(draw, home_text, (width - home_w, record_y), record_font) + + def _display_live_game(self, game: Dict): + """Display a live baseball game with full scorebug: bases, outs, count, inning.""" + matrix_width = self.display_manager.matrix.width + matrix_height = self.display_manager.matrix.height + + main_img = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 255)) + overlay = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # Logos + home_logo, away_logo = self._paste_logos(main_img, game, inward_offset=10) + if not home_logo or not away_logo: + self._display_text_fallback(game) + return + + # --- Inning indicator (top center) --- + inning_half = game.get('inning_half', 'top') + inning_num = game.get('inning', 1) + if game.get('is_final'): + inning_text = "FINAL" + else: + symbol = "▲" if inning_half == 'top' else "▼" + inning_text = f"{symbol}{inning_num}" + + inning_font = self.fonts['time'] + inning_bbox = draw.textbbox((0, 0), inning_text, font=inning_font) + inning_width = inning_bbox[2] - inning_bbox[0] + inning_x = (matrix_width - inning_width) // 2 + self._get_layout_offset('status', 'x_offset') + inning_y = 1 + self._get_layout_offset('status', 'y_offset') + self._draw_text_with_outline(draw, inning_text, (inning_x, inning_y), inning_font) + + # --- Bases diamond + Outs circles --- + bases_occupied = game.get('bases_occupied', [False, False, False]) + outs = game.get('outs', 0) + + # Geometry constants (from v2.5) + base_diamond_size = 7 + out_circle_diameter = 3 + out_vertical_spacing = 2 + spacing_between_bases_outs = 3 + base_vert_spacing = 1 + base_horiz_spacing = 1 + + base_cluster_height = base_diamond_size + base_vert_spacing + base_diamond_size + base_cluster_width = base_diamond_size + base_horiz_spacing + base_diamond_size + out_cluster_height = 3 * out_circle_diameter + 2 * out_vertical_spacing + + overall_start_y = inning_bbox[3] + 1 # just below inning text + + # Center bases horizontally + bases_origin_x = (matrix_width - base_cluster_width) // 2 + + # Outs column position depends on inning half + if inning_half == 'top': + outs_column_x = bases_origin_x - spacing_between_bases_outs - out_circle_diameter + else: + outs_column_x = bases_origin_x + base_cluster_width + spacing_between_bases_outs + + outs_column_start_y = overall_start_y + (base_cluster_height // 2) - (out_cluster_height // 2) + + # Draw bases as diamond polygons + base_color_filled = (255, 255, 255) + base_color_outline = (255, 255, 255) + h_d = base_diamond_size // 2 + + # 2nd base (top center) + c2x = bases_origin_x + base_cluster_width // 2 + c2y = overall_start_y + h_d + poly2 = [(c2x, overall_start_y), (c2x + h_d, c2y), (c2x, c2y + h_d), (c2x - h_d, c2y)] + if bases_occupied[1]: + draw.polygon(poly2, fill=base_color_filled) + else: + draw.polygon(poly2, outline=base_color_outline) + + base_bottom_y = c2y + h_d + + # 3rd base (bottom left) + c3x = bases_origin_x + h_d + c3y = base_bottom_y + base_vert_spacing + h_d + poly3 = [(c3x, base_bottom_y + base_vert_spacing), (c3x + h_d, c3y), (c3x, c3y + h_d), (c3x - h_d, c3y)] + if bases_occupied[2]: + draw.polygon(poly3, fill=base_color_filled) + else: + draw.polygon(poly3, outline=base_color_outline) + + # 1st base (bottom right) + c1x = bases_origin_x + base_cluster_width - h_d + c1y = base_bottom_y + base_vert_spacing + h_d + poly1 = [(c1x, base_bottom_y + base_vert_spacing), (c1x + h_d, c1y), (c1x, c1y + h_d), (c1x - h_d, c1y)] + if bases_occupied[0]: + draw.polygon(poly1, fill=base_color_filled) + else: + draw.polygon(poly1, outline=base_color_outline) + + # Draw outs (3 vertical circles) + circle_color_filled = (255, 255, 255) + circle_color_empty = (100, 100, 100) + + for i in range(3): + cx = outs_column_x + cy = outs_column_start_y + i * (out_circle_diameter + out_vertical_spacing) + coords = [cx, cy, cx + out_circle_diameter, cy + out_circle_diameter] + if i < outs: + draw.ellipse(coords, fill=circle_color_filled) + else: + draw.ellipse(coords, outline=circle_color_empty) + + # --- Balls-strikes count (below bases) --- + balls = game.get('balls', 0) + strikes = game.get('strikes', 0) + count_text = f"{balls}-{strikes}" + count_font = self.fonts['detail'] + count_width = draw.textlength(count_text, font=count_font) + cluster_bottom_y = overall_start_y + base_cluster_height + count_y = cluster_bottom_y + 2 + count_x = bases_origin_x + (base_cluster_width - count_width) // 2 + self._draw_text_with_outline(draw, count_text, (int(count_x), count_y), count_font) + + # --- Team:Score at bottom corners --- + score_font = self.fonts['score'] + away_abbr = game.get('away_abbr', '') + home_abbr = game.get('home_abbr', '') + away_score_str = str(game.get('away_score', '0')) + home_score_str = str(game.get('home_score', '0')) + away_text = f"{away_abbr}:{away_score_str}" + home_text = f"{home_abbr}:{home_score_str}" + + try: + font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1] + except AttributeError: + font_height = 8 + score_y = matrix_height - font_height - 2 + + # Away (bottom left) + self._draw_text_with_outline(draw, away_text, (2, score_y), score_font) + + # Home (bottom right) + try: + home_text_width = draw.textbbox((0, 0), home_text, font=score_font)[2] + except AttributeError: + home_text_width = len(home_text) * 8 + self._draw_text_with_outline(draw, home_text, (matrix_width - home_text_width - 2, score_y), score_font) + + # Composite and display + final_img = Image.alpha_composite(main_img, overlay) + self.display_manager.image = final_img.convert('RGB').copy() + self.display_manager.update_display() + + def _display_recent_game(self, game: Dict): + """Display a recent (final) baseball game.""" + matrix_width = self.display_manager.matrix.width + matrix_height = self.display_manager.matrix.height + + main_img = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 255)) + overlay = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # Logos (tighter fit for recent/upcoming) + home_logo, away_logo = self._paste_logos(main_img, game, inward_offset=2) + if not home_logo or not away_logo: + self._display_text_fallback(game) + return + + # "Final" text (top center) + status_text = "Final" + status_font = self.fonts['time'] + status_width = draw.textlength(status_text, font=status_font) + status_x = (matrix_width - status_width) // 2 + self._get_layout_offset('status', 'x_offset') + status_y = 1 + self._get_layout_offset('status', 'y_offset') + self._draw_text_with_outline(draw, status_text, (status_x, status_y), status_font) + + # Score (centered) + away_score = str(game.get('away_score', '0')) + home_score = str(game.get('home_score', '0')) + score_text = f"{away_score}-{home_score}" + score_font = self.fonts['score'] + score_width = draw.textlength(score_text, font=score_font) + score_x = (matrix_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') + score_y = matrix_height - 14 + self._get_layout_offset('score', 'y_offset') + self._draw_text_with_outline(draw, score_text, (score_x, score_y), score_font, fill=(255, 200, 0)) + + # Records at bottom corners + self._draw_records(draw, game, matrix_width, matrix_height) + + # Series summary (centered, if available) + series_summary = game.get('series_summary', '') + if series_summary: + series_font = self.fonts['time'] + series_width = draw.textlength(series_summary, font=series_font) + series_bbox = draw.textbbox((0, 0), series_summary, font=series_font) + series_height = series_bbox[3] - series_bbox[1] + series_x = (matrix_width - series_width) // 2 + series_y = (matrix_height - series_height) // 2 + self._draw_text_with_outline(draw, series_summary, (series_x, series_y), series_font) + + # Composite and display + final_img = Image.alpha_composite(main_img, overlay) + self.display_manager.image = final_img.convert('RGB').copy() + self.display_manager.update_display() + + def _display_upcoming_game(self, game: Dict): + """Display an upcoming baseball game.""" + matrix_width = self.display_manager.matrix.width + matrix_height = self.display_manager.matrix.height + + main_img = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 255)) + overlay = Image.new('RGBA', (matrix_width, matrix_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # Logos (tighter fit) + home_logo, away_logo = self._paste_logos(main_img, game, inward_offset=2) + if not home_logo or not away_logo: + self._display_text_fallback(game) + return + + # "Next Game" (top center) + status_font = self.fonts['status'] if matrix_width <= 128 else self.fonts['time'] + status_text = "Next Game" + status_width = draw.textlength(status_text, font=status_font) + status_x = (matrix_width - status_width) // 2 + self._get_layout_offset('status', 'x_offset') + status_y = 1 + self._get_layout_offset('status', 'y_offset') + self._draw_text_with_outline(draw, status_text, (status_x, status_y), status_font) + + # Parse start time for date and time display + center_y = matrix_height // 2 + game_date = '' + game_time = '' + start_time = game.get('start_time', '') + if start_time: + try: + dt = datetime.fromisoformat(start_time.replace('Z', '+00:00')) + local_tz = pytz.timezone(self.config.get('timezone', 'US/Eastern')) + dt_local = dt.astimezone(local_tz) + game_date = dt_local.strftime('%b %d') + game_time = dt_local.strftime('%-I:%M %p') + except (ValueError, AttributeError): + game_date = '' + game_time = start_time[:10] if len(start_time) > 10 else start_time + + # Date (centered) + time_font = self.fonts['time'] + if game_date: + date_width = draw.textlength(game_date, font=time_font) + date_x = (matrix_width - date_width) // 2 + date_y = center_y - 7 + self._draw_text_with_outline(draw, game_date, (date_x, date_y), time_font) + + # Time (centered, below date) + if game_time: + time_width = draw.textlength(game_time, font=time_font) + time_x = (matrix_width - time_width) // 2 + time_y = center_y + 2 + self._draw_text_with_outline(draw, game_time, (time_x, time_y), time_font) + + # Records at bottom corners + self._draw_records(draw, game, matrix_width, matrix_height) + + # Composite and display + final_img = Image.alpha_composite(main_img, overlay) + self.display_manager.image = final_img.convert('RGB').copy() + self.display_manager.update_display() + + def _display_text_fallback(self, game: Dict): + """Text-only fallback when logos fail to load.""" + matrix_width = self.display_manager.matrix.width + matrix_height = self.display_manager.matrix.height + img = Image.new('RGB', (matrix_width, matrix_height), (0, 0, 0)) + draw = ImageDraw.Draw(img) + + away_abbr = game.get('away_abbr', 'AWAY') + home_abbr = game.get('home_abbr', 'HOME') + + draw.text((5, 5), f"{away_abbr} @ {home_abbr}", fill=(255, 255, 255)) + draw.text((5, 15), f"{game.get('away_score', 0)} - {game.get('home_score', 0)}", fill=(255, 200, 0)) + draw.text((5, 25), game.get('status_text', ''), fill=(0, 255, 0)) + + self.display_manager.image = img.copy() + self.display_manager.update_display() + def _display_no_games(self, mode: str): """Display message when no games are available.""" img = Image.new('RGB', (self.display_manager.matrix.width, @@ -857,9 +1212,9 @@ def get_info(self) -> Dict[str, Any]: # Access current_games under lock for thread safety with self._games_lock: total_games = len(self.current_games) - live_games = len([g for g in self.current_games if g.get('status', {}).get('state') == 'in']) - recent_games = len([g for g in self.current_games if g.get('status', {}).get('state') == 'post']) - upcoming_games = len([g for g in self.current_games if g.get('status', {}).get('state') == 'pre']) + live_games = len([g for g in self.current_games if g.get('is_live')]) + recent_games = len([g for g in self.current_games if g.get('is_final')]) + upcoming_games = len([g for g in self.current_games if g.get('is_upcoming')]) info.update({ 'total_games': total_games, @@ -919,12 +1274,11 @@ def _collect_games_for_scroll(self) -> tuple: continue # Determine game type from state - state = game.get('status', {}).get('state', '') - if state == 'in': + if game.get('is_live'): game_type = 'live' - elif state == 'post': + elif game.get('is_final'): game_type = 'recent' - elif state == 'pre': + elif game.get('is_upcoming'): game_type = 'upcoming' else: continue @@ -946,11 +1300,9 @@ def _collect_games_for_scroll(self) -> tuple: for league in leagues: # Sort games within league: live first, then recent, then upcoming league_games = games_by_league[league] - league_games.sort(key=lambda g: { - 'in': 0, # live first - 'post': 1, # recent second - 'pre': 2 # upcoming third - }.get(g.get('status', {}).get('state', ''), 3)) + league_games.sort(key=lambda g: ( + 0 if g.get('is_live') else 1 if g.get('is_final') else 2 + )) all_games.extend(league_games) @@ -1031,12 +1383,11 @@ def _ensure_scroll_content_for_vegas(self) -> None: # Count games by type for logging game_type_counts = {'live': 0, 'recent': 0, 'upcoming': 0} for game in games: - state = game.get('status', {}).get('state', '') - if state == 'in': + if game.get('is_live'): game_type_counts['live'] += 1 - elif state == 'post': + elif game.get('is_final'): game_type_counts['recent'] += 1 - elif state == 'pre': + elif game.get('is_upcoming'): game_type_counts['upcoming'] += 1 # Prepare scroll content with mixed game types diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 94d4623..3535d90 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.0.5", + "version": "1.1.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -24,6 +24,11 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-02-13", + "version": "1.1.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-02-12", "version": "1.0.5", @@ -45,7 +50,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-02-12", + "last_updated": "2026-02-13", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/baseball-scoreboard/scorebug_renderer.py b/plugins/baseball-scoreboard/scorebug_renderer.py deleted file mode 100644 index b9b1fb5..0000000 --- a/plugins/baseball-scoreboard/scorebug_renderer.py +++ /dev/null @@ -1,693 +0,0 @@ -""" -Baseball Scorebug Renderer - -Handles all drawing logic for live, recent, and upcoming baseball games. -Replicates functionality from BaseballLive, SportsRecent, and SportsUpcoming. -""" - -import logging -import time -from datetime import datetime -from pathlib import Path -from typing import Any, Dict, Optional - -import pytz -from PIL import Image, ImageDraw, ImageFont -import freetype - -from logo_manager import BaseballLogoManager -from odds_manager import BaseballOddsManager -from rankings_manager import BaseballRankingsManager - - -class BaseballScorebugRenderer: - """Renders scorebugs for all baseball game modes.""" - - def __init__(self, display_manager, logo_manager: BaseballLogoManager, - odds_manager: BaseballOddsManager, - rankings_manager: BaseballRankingsManager, - logger: logging.Logger): - """ - Initialize the scorebug renderer. - - Args: - display_manager: Display manager instance - logo_manager: Logo manager instance - odds_manager: Odds manager instance - rankings_manager: Rankings manager instance - logger: Logger instance - """ - self.display_manager = display_manager - self.logo_manager = logo_manager - self.odds_manager = odds_manager - self.rankings_manager = rankings_manager - self.logger = logger - - # Get display dimensions - if display_manager and hasattr(display_manager, 'matrix') and display_manager.matrix is not None: - self.display_width = display_manager.matrix.width - self.display_height = display_manager.matrix.height - elif display_manager: - # Fallback to width/height properties (which also check matrix) - self.display_width = getattr(display_manager, "width", 128) - self.display_height = getattr(display_manager, "height", 32) - else: - self.display_width = 128 - self.display_height = 32 - - # Load fonts - self.fonts = self._load_fonts() - - def _load_fonts(self) -> Dict: - """ - Load fonts used by the scoreboard. - - Returns: - Dictionary of font objects - """ - fonts = {} - try: - fonts['score'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) - fonts['time'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - fonts['detail'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - fonts['rank'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) - self.logger.info("Successfully loaded fonts") - except IOError: - self.logger.warning("Fonts not found, using default PIL font.") - fonts['score'] = ImageFont.load_default() - fonts['time'] = ImageFont.load_default() - fonts['team'] = ImageFont.load_default() - fonts['status'] = ImageFont.load_default() - fonts['detail'] = ImageFont.load_default() - fonts['rank'] = ImageFont.load_default() - return fonts - - def _draw_text_with_outline(self, draw: ImageDraw.Draw, text: str, position: tuple, - font, fill=(255, 255, 255), outline_color=(0, 0, 0)) -> None: - """ - Draw text with a black outline for better readability. - - Args: - draw: ImageDraw instance - text: Text to draw - position: (x, y) position tuple - font: Font to use - fill: Text color (default: white) - outline_color: Outline color (default: black) - """ - x, y = position - for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) - - def render_live_scorebug(self, game: Dict, league_config: Dict, league_key: str = None) -> None: - """ - Render a live game scorebug. - - Replicates BaseballLive._draw_scorebug_layout() logic. - - Args: - game: Game dictionary with all game details - league_config: League-specific configuration - league_key: League identifier (for MiLB-specific handling) - """ - try: - # MiLB uses different rendering - if league_key == 'milb': - self._render_milb_live_scorebug(game, league_config) - return - - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) - - # Load logos - home_abbr = game.get('home_abbr', '') - away_abbr = game.get('away_abbr', '') - home_id = game.get('home_id', '') - away_id = game.get('away_id', '') - home_logo_path = game.get('home_logo_path') - away_logo_path = game.get('away_logo_path') - home_logo_url = game.get('home_logo_url') - away_logo_url = game.get('away_logo_url') - - sport_key = league_key or 'baseball' - home_logo = self.logo_manager.load_logo( - home_id, home_abbr, home_logo_path, home_logo_url, sport_key - ) - away_logo = self.logo_manager.load_logo( - away_id, away_abbr, away_logo_path, away_logo_url, sport_key - ) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for live game: {game.get('id')}") - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5, 5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # Draw logos (shifted slightly inward) - home_x = self.display_width - home_logo.width + 10 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -10 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Live Game Specific Elements - text_color = (255, 255, 255) - - # Draw Inning (Top Center) - inning_half = game.get('inning_half', 'top') - inning_num = game.get('inning', 1) - - if game.get('is_final', False): - inning_text = "FINAL" - else: - inning_half_indicator = "▲" if inning_half.lower() == "top" else "▼" - inning_text = f"{inning_half_indicator}{inning_num}" - - # Use display_manager font for inning - inning_font = getattr(self.display_manager, 'font', self.fonts['time']) - try: - inning_bbox = draw_overlay.textbbox((0, 0), inning_text, font=inning_font) - inning_width = inning_bbox[2] - inning_bbox[0] - except AttributeError: - # Fallback for older PIL - inning_width = len(inning_text) * 8 - inning_x = (self.display_width - inning_width) // 2 - inning_y = 1 - self._draw_text_with_outline(draw_overlay, inning_text, (inning_x, inning_y), inning_font) - - # Draw bases and outs - bases_occupied = game.get('bases_occupied', [False, False, False]) - outs = game.get('outs', 0) - - # Define geometry - base_diamond_size = 7 - out_circle_diameter = 3 - out_vertical_spacing = 2 - spacing_between_bases_outs = 3 - base_vert_spacing = 1 - base_horiz_spacing = 1 - - base_cluster_height = base_diamond_size + base_vert_spacing + base_diamond_size - base_cluster_width = base_diamond_size + base_horiz_spacing + base_diamond_size - out_cluster_height = 3 * out_circle_diameter + 2 * out_vertical_spacing - out_cluster_width = out_circle_diameter - - overall_start_y = inning_bbox[3] if 'inning_bbox' in locals() else 9 - - # Center the BASE cluster horizontally - bases_origin_x = (self.display_width - base_cluster_width) // 2 - - # Determine relative positions for outs based on inning half - if inning_half == "top": - outs_column_x = bases_origin_x - spacing_between_bases_outs - out_cluster_width - else: - outs_column_x = bases_origin_x + base_cluster_width + spacing_between_bases_outs - - outs_column_start_y = overall_start_y + (base_cluster_height // 2) - (out_cluster_height // 2) - - # Draw Bases (Diamonds) - base_color_occupied = (255, 255, 255) - base_color_empty = (255, 255, 255) - h_d = base_diamond_size // 2 - - # 2nd Base (Top center) - c2x = bases_origin_x + base_cluster_width // 2 - c2y = overall_start_y + h_d - poly2 = [(c2x, overall_start_y), (c2x + h_d, c2y), (c2x, c2y + h_d), (c2x - h_d, c2y)] - if bases_occupied[1]: - draw_overlay.polygon(poly2, fill=base_color_occupied) - else: - draw_overlay.polygon(poly2, outline=base_color_empty) - - base_bottom_y = c2y + h_d - - # 3rd Base (Bottom left) - c3x = bases_origin_x + h_d - c3y = base_bottom_y + base_vert_spacing + h_d - poly3 = [(c3x, base_bottom_y + base_vert_spacing), (c3x + h_d, c3y), (c3x, c3y + h_d), (c3x - h_d, c3y)] - if bases_occupied[2]: - draw_overlay.polygon(poly3, fill=base_color_occupied) - else: - draw_overlay.polygon(poly3, outline=base_color_empty) - - # 1st Base (Bottom right) - c1x = bases_origin_x + base_cluster_width - h_d - c1y = base_bottom_y + base_vert_spacing + h_d - poly1 = [(c1x, base_bottom_y + base_vert_spacing), (c1x + h_d, c1y), (c1x, c1y + h_d), (c1x - h_d, c1y)] - if bases_occupied[0]: - draw_overlay.polygon(poly1, fill=base_color_occupied) - else: - draw_overlay.polygon(poly1, outline=base_color_empty) - - # Draw Outs (Vertical Circles) - circle_color_out = (255, 255, 255) - circle_color_empty_outline = (100, 100, 100) - - for i in range(3): - cx = outs_column_x - cy = outs_column_start_y + i * (out_circle_diameter + out_vertical_spacing) - coords = [cx, cy, cx + out_circle_diameter, cy + out_circle_diameter] - if i < outs: - draw_overlay.ellipse(coords, fill=circle_color_out) - else: - draw_overlay.ellipse(coords, outline=circle_color_empty_outline) - - # Draw Balls-Strikes Count - balls = game.get('balls', 0) - strikes = game.get('strikes', 0) - count_text = f"{balls}-{strikes}" - - # Try to use BDF font if available, otherwise fallback to TTF - bdf_font = getattr(self.display_manager, 'calendar_font', None) - if bdf_font and isinstance(bdf_font, freetype.Face): - try: - bdf_font.set_char_size(height=7 * 64) - count_text_width = self.display_manager.get_text_width(count_text, bdf_font) - using_bdf = True - except Exception: - count_text_width = draw_overlay.textlength(count_text, font=self.fonts['detail']) - using_bdf = False - else: - count_text_width = draw_overlay.textlength(count_text, font=self.fonts['detail']) - using_bdf = False - - cluster_bottom_y = overall_start_y + base_cluster_height - count_y = cluster_bottom_y + 2 - count_x = bases_origin_x + (base_cluster_width - count_text_width) // 2 - - if using_bdf: - self.display_manager.draw = draw_overlay - outline_color_for_bdf = (0, 0, 0) - for dx_offset, dy_offset in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: - self.display_manager._draw_bdf_text( - count_text, count_x + dx_offset, count_y + dy_offset, - color=outline_color_for_bdf, font=bdf_font - ) - self.display_manager._draw_bdf_text(count_text, count_x, count_y, color=text_color, font=bdf_font) - else: - self._draw_text_with_outline(draw_overlay, count_text, (count_x, count_y), self.fonts['detail']) - - # Draw Team:Score at the bottom - score_font = getattr(self.display_manager, 'font', self.fonts['score']) - score_text_color = (255, 255, 255) - outline_color = (0, 0, 0) - - away_score_str = str(game.get('away_score', '0')) - home_score_str = str(game.get('home_score', '0')) - away_text = f"{away_abbr}:{away_score_str}" - home_text = f"{home_abbr}:{home_score_str}" - - try: - font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1] - except AttributeError: - font_height = 8 - score_y = self.display_height - font_height - 2 - - # Away Team:Score (Bottom Left) - away_score_x = 2 - self._draw_text_with_outline(draw_overlay, away_text, (away_score_x, score_y), score_font, fill=score_text_color, outline_color=outline_color) - - # Home Team:Score (Bottom Right) - try: - home_text_bbox = draw_overlay.textbbox((0, 0), home_text, font=score_font) - home_text_width = home_text_bbox[2] - home_text_bbox[0] - except AttributeError: - home_text_width = len(home_text) * 8 - home_score_x = self.display_width - home_text_width - 2 - self._draw_text_with_outline(draw_overlay, home_text, (home_score_x, score_y), score_font, fill=score_text_color, outline_color=outline_color) - - # Draw odds if available - if 'odds' in game and game['odds']: - self.odds_manager.render_odds(draw_overlay, game['odds'], self.display_width, self.display_height, self.fonts) - - # Composite and display - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() - - except Exception as e: - self.logger.error(f"Error rendering live scorebug: {e}", exc_info=True) - - def render_recent_scorebug(self, game: Dict, league_config: Dict, league_key: str = None) -> None: - """ - Render a recent (final) game scorebug. - - Replicates SportsRecent._draw_scorebug_layout() logic. - - Args: - game: Game dictionary - league_config: League-specific configuration - league_key: League identifier - """ - try: - # MiLB uses different rendering - if league_key == 'milb': - self._render_milb_recent_scorebug(game, league_config) - return - - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) - - # Load logos - home_abbr = game.get('home_abbr', '') - away_abbr = game.get('away_abbr', '') - home_id = game.get('home_id', '') - away_id = game.get('away_id', '') - home_logo_path = game.get('home_logo_path') - away_logo_path = game.get('away_logo_path') - home_logo_url = game.get('home_logo_url') - away_logo_url = game.get('away_logo_url') - - sport_key = league_key or 'baseball' - home_logo = self.logo_manager.load_logo( - home_id, home_abbr, home_logo_path, home_logo_url, sport_key - ) - away_logo = self.logo_manager.load_logo( - away_id, away_abbr, away_logo_path, away_logo_url, sport_key - ) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for recent game: {game.get('id')}") - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5, 5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # MLB-style logo positioning - home_x = self.display_width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -2 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Final Scores (Centered) - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = self.display_height - 14 - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) - - # "Final" text (Top center) - status_text = game.get("period_text", "Final") - status_width = draw_overlay.textlength(status_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 1 - self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), self.fonts['time']) - - # Draw odds if available - if 'odds' in game and game['odds']: - self.odds_manager.render_odds(draw_overlay, game['odds'], self.display_width, self.display_height, self.fonts) - - # Draw records or rankings if enabled - show_records = league_config.get('show_records', False) - show_ranking = league_config.get('show_ranking', False) - - if show_records or show_ranking: - try: - record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - except IOError: - record_font = ImageFont.load_default() - - record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - - # Display away team info - if away_abbr: - away_text = self._get_team_display_text( - away_abbr, game, league_config, league_key, show_records, show_ranking - ) - if away_text: - away_record_x = 0 - self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - - # Display home team info - if home_abbr: - home_text = self._get_team_display_text( - home_abbr, game, league_config, league_key, show_records, show_ranking - ) - if home_text: - home_record_bbox = draw_overlay.textbbox((0, 0), home_text, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) - - # Draw series summary if enabled (replicates BaseballRecent.display_series_summary) - show_series_summary = league_config.get('show_series_summary', False) - if show_series_summary: - series_summary = game.get("series_summary", "") - if series_summary: - try: - series_font = self.fonts['time'] - series_bbox = draw_overlay.textbbox((0, 0), series_summary, font=series_font) - series_height = series_bbox[3] - series_bbox[1] - series_y = (self.display_height - series_height) // 2 - series_width = draw_overlay.textlength(series_summary, font=series_font) - series_x = (self.display_width - series_width) // 2 - self._draw_text_with_outline(draw_overlay, series_summary, (series_x, series_y), series_font) - except Exception as e: - self.logger.warning(f"Error drawing series summary: {e}") - - # Composite and display - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() - - except Exception as e: - self.logger.error(f"Error rendering recent scorebug: {e}", exc_info=True) - - def render_upcoming_scorebug(self, game: Dict, league_config: Dict, league_key: str = None) -> None: - """ - Render an upcoming game scorebug. - - Replicates SportsUpcoming._draw_scorebug_layout() logic. - - Args: - game: Game dictionary - league_config: League-specific configuration - league_key: League identifier - """ - try: - # MiLB uses different rendering - if league_key == 'milb': - self._render_milb_upcoming_scorebug(game, league_config) - return - - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - draw_overlay = ImageDraw.Draw(overlay) - - # Load logos - home_abbr = game.get('home_abbr', '') - away_abbr = game.get('away_abbr', '') - home_id = game.get('home_id', '') - away_id = game.get('away_id', '') - home_logo_path = game.get('home_logo_path') - away_logo_path = game.get('away_logo_path') - home_logo_url = game.get('home_logo_url') - away_logo_url = game.get('away_logo_url') - - sport_key = league_key or 'baseball' - home_logo = self.logo_manager.load_logo( - home_id, home_abbr, home_logo_path, home_logo_url, sport_key - ) - away_logo = self.logo_manager.load_logo( - away_id, away_abbr, away_logo_path, away_logo_url, sport_key - ) - - if not home_logo or not away_logo: - self.logger.error(f"Failed to load logos for upcoming game: {game.get('id')}") - draw_final = ImageDraw.Draw(main_img.convert('RGB')) - self._draw_text_with_outline(draw_final, "Logo Error", (5, 5), self.fonts['status']) - self.display_manager.image.paste(main_img.convert('RGB'), (0, 0)) - self.display_manager.update_display() - return - - center_y = self.display_height // 2 - - # MLB-style logo positions - home_x = self.display_width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - main_img.paste(home_logo, (home_x, home_y), home_logo) - - away_x = -2 - away_y = center_y - (away_logo.height // 2) - main_img.paste(away_logo, (away_x, away_y), away_logo) - - # Draw Text Elements on Overlay - game_date = game.get("game_date", "") - game_time = game.get("game_time", "") - - # "Next Game" at the top - status_font = self.fonts['status'] - if self.display_width > 128: - status_font = self.fonts['time'] - status_text = "Next Game" - status_width = draw_overlay.textlength(status_text, font=status_font) - status_x = (self.display_width - status_width) // 2 - status_y = 1 - self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), status_font) - - # Date text (centered, below "Next Game") - date_width = draw_overlay.textlength(game_date, font=self.fonts['time']) - date_x = (self.display_width - date_width) // 2 - date_y = center_y - 7 - self._draw_text_with_outline(draw_overlay, game_date, (date_x, date_y), self.fonts['time']) - - # Time text (centered, below Date) - time_width = draw_overlay.textlength(game_time, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 - time_y = date_y + 9 - self._draw_text_with_outline(draw_overlay, game_time, (time_x, time_y), self.fonts['time']) - - # Draw odds if available - if 'odds' in game and game['odds']: - self.odds_manager.render_odds(draw_overlay, game['odds'], self.display_width, self.display_height, self.fonts) - - # Draw records or rankings if enabled - show_records = league_config.get('show_records', False) - show_ranking = league_config.get('show_ranking', False) - - if show_records or show_ranking: - try: - record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - except IOError: - record_font = ImageFont.load_default() - - record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - - # Display away team info - if away_abbr: - away_text = self._get_team_display_text( - away_abbr, game, league_config, league_key, show_records, show_ranking - ) - if away_text: - away_record_x = 0 - self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - - # Display home team info - if home_abbr: - home_text = self._get_team_display_text( - home_abbr, game, league_config, league_key, show_records, show_ranking - ) - if home_text: - home_record_bbox = draw_overlay.textbbox((0, 0), home_text, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) - - # Composite and display - main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() - - except Exception as e: - self.logger.error(f"Error rendering upcoming scorebug: {e}", exc_info=True) - - def _get_team_display_text(self, team_abbr: str, game: Dict, league_config: Dict, - league_key: str, show_records: bool, show_ranking: bool) -> str: - """ - Get display text for team (ranking or record). - - Args: - team_abbr: Team abbreviation - game: Game dictionary - league_config: League-specific configuration - league_key: League identifier - show_records: Whether to show records - show_ranking: Whether to show rankings - - Returns: - Display text string - """ - if show_ranking and show_records: - # When both rankings and records are enabled, rankings replace records completely - team_rank = self.rankings_manager.get_team_rank(team_abbr, league_key or 'baseball') - if team_rank > 0: - return f"#{team_rank}" - return '' - elif show_ranking: - # Show ranking only if available - team_rank = self.rankings_manager.get_team_rank(team_abbr, league_key or 'baseball') - if team_rank > 0: - return f"#{team_rank}" - return '' - elif show_records: - # Show record only when rankings are disabled - if team_abbr == game.get('away_abbr', ''): - return game.get('away_record', '') - elif team_abbr == game.get('home_abbr', ''): - return game.get('home_record', '') - return '' - return '' - - def _render_milb_live_scorebug(self, game: Dict, league_config: Dict) -> None: - """ - Render MiLB live game scorebug (replicates MiLBLiveManager._create_live_game_display). - - Args: - game: Game dictionary - league_config: League-specific configuration - """ - try: - from src.old_managers.milb_manager import MiLBLiveManager - - # For now, use a simplified version that matches ESPN format - # Full MiLB rendering would require MiLB-specific logic from milb_manager.py - # This is a placeholder that renders in ESPN format - self.render_live_scorebug(game, league_config, league_key='milb') - - except Exception as e: - self.logger.error(f"Error rendering MiLB live scorebug: {e}", exc_info=True) - - def _render_milb_recent_scorebug(self, game: Dict, league_config: Dict) -> None: - """ - Render MiLB recent game scorebug. - - Args: - game: Game dictionary - league_config: League-specific configuration - """ - try: - # Simplified version - full implementation would match MiLB manager - self.render_recent_scorebug(game, league_config, league_key='milb') - except Exception as e: - self.logger.error(f"Error rendering MiLB recent scorebug: {e}", exc_info=True) - - def _render_milb_upcoming_scorebug(self, game: Dict, league_config: Dict) -> None: - """ - Render MiLB upcoming game scorebug. - - Args: - game: Game dictionary - league_config: League-specific configuration - """ - try: - # Simplified version - full implementation would match MiLB manager - self.render_upcoming_scorebug(game, league_config, league_key='milb') - except Exception as e: - self.logger.error(f"Error rendering MiLB upcoming scorebug: {e}", exc_info=True) - diff --git a/plugins/baseball-scoreboard/scroll_display.py b/plugins/baseball-scoreboard/scroll_display.py index 0edb8bd..7f93fd9 100644 --- a/plugins/baseball-scoreboard/scroll_display.py +++ b/plugins/baseball-scoreboard/scroll_display.py @@ -279,12 +279,11 @@ def _determine_game_type(self, game: Dict) -> str: Returns: Game type: 'live', 'recent', or 'upcoming' """ - state = game.get('status', {}).get('state', '') - if state == 'in': + if game.get('is_live'): return 'live' - elif state == 'post': + elif game.get('is_final'): return 'recent' - elif state == 'pre': + elif game.get('is_upcoming'): return 'upcoming' else: # Default to upcoming if state is unknown From ab3b8421251776f23ea729d21200a5a8fc07f51c Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 13 Feb 2026 21:20:51 -0500 Subject: [PATCH 02/15] fix(baseball): fix inning display, count fallback, and records config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix 'end of inning' rendering: don't increment period when 'end' is detected in status text, use distinct 'end'/'mid' inning_half values and render as E5/M5 markers instead of misleading ▲ arrows - Fix count fallback: check whether count dict is present/populated rather than testing for 0-0 values, which treated valid 0-0 counts as missing data - Fix game_renderer.py _draw_records: gate on show_records/show_ranking config flags from league_config to match manager.py behavior, so scroll mode respects user settings Co-Authored-By: Claude Opus 4.6 --- plugins/baseball-scoreboard/game_renderer.py | 13 ++++++++++- plugins/baseball-scoreboard/manager.py | 24 ++++++++++++-------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 7d54bc2..a67fa5e 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -169,6 +169,10 @@ def _render_live_game(self, game: Dict) -> Image.Image: inning_num = game.get('inning', 1) if game.get('is_final'): inning_text = "FINAL" + elif inning_half == 'end': + inning_text = f"E{inning_num}" + elif inning_half == 'mid': + inning_text = f"M{inning_num}" else: symbol = "▲" if inning_half == 'top' else "▼" inning_text = f"{symbol}{inning_num}" @@ -378,7 +382,14 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: return self._render_error_card("Display error") def _draw_records(self, draw, game: Dict): - """Draw team records at bottom corners.""" + """Draw team records at bottom corners if enabled by config.""" + league_config = game.get('league_config', {}) + show_records = league_config.get('show_records', self.config.get('show_records', False)) + show_ranking = league_config.get('show_ranking', self.config.get('show_ranking', False)) + + if not show_records and not show_ranking: + return + away_record = game.get('away_record', '') home_record = game.get('home_record', '') if not away_record and not home_record: diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 59e333e..bb1d6ba 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -566,31 +566,33 @@ def _extract_game_info(self, event: Dict, league_key: str, league_config: Dict) # Determine inning half from status text inning_half = 'top' if 'end' in status_detail or 'end' in status_short: - inning_half = 'top' - inning = status.get('period', 1) + 1 + inning_half = 'end' elif 'mid' in status_detail or 'mid' in status_short: - inning_half = 'bottom' + inning_half = 'mid' elif 'bottom' in status_detail or 'bot' in status_detail or 'bottom' in status_short or 'bot' in status_short: inning_half = 'bottom' elif 'top' in status_detail or 'top' in status_short: inning_half = 'top' # Get count and bases from situation - count = situation.get('count', {}) if situation else {} - balls = count.get('balls', 0) - strikes = count.get('strikes', 0) + count = situation.get('count') if situation else None outs = situation.get('outs', 0) if situation else 0 - # Try alternative locations for count data - if balls == 0 and strikes == 0 and situation: + if count: + balls = count.get('balls', 0) + strikes = count.get('strikes', 0) + elif situation: + # Try alternative locations for count data if 'summary' in situation: try: balls, strikes = map(int, situation['summary'].split('-')) except (ValueError, AttributeError): - pass + balls, strikes = 0, 0 else: balls = situation.get('balls', 0) strikes = situation.get('strikes', 0) + else: + balls, strikes = 0, 0 bases_occupied = [ situation.get('onFirst', False) if situation else False, @@ -901,6 +903,10 @@ def _display_live_game(self, game: Dict): inning_num = game.get('inning', 1) if game.get('is_final'): inning_text = "FINAL" + elif inning_half == 'end': + inning_text = f"E{inning_num}" + elif inning_half == 'mid': + inning_text = f"M{inning_num}" else: symbol = "▲" if inning_half == 'top' else "▼" inning_text = f"{symbol}{inning_num}" From 5db7e6188e8383c706542c00f301e66786846fde Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 13 Feb 2026 21:45:53 -0500 Subject: [PATCH 03/15] feat(baseball): integrate odds rendering, fix scroll logo paths - Wire up BaseballOddsManager in manager.py: import, initialize, and call fetch_odds/render_odds in all three display methods (live, recent, upcoming) when show_odds is enabled - Add _draw_dynamic_odds() to game_renderer.py for scroll mode cards, matching the pattern used by football/basketball plugins - Fix game_renderer.py _get_logo_path to read logo_dir from league_config instead of hardcoding paths, so custom logo directories (MiLB, NCAA) are respected in scroll mode - Move datetime/pytz imports to module level in game_renderer.py Co-Authored-By: Claude Opus 4.6 --- plugins.json | 2 +- plugins/baseball-scoreboard/game_renderer.py | 104 ++++++++++++++++--- plugins/baseball-scoreboard/manager.py | 40 +++++++ plugins/baseball-scoreboard/manifest.json | 9 +- 4 files changed, 139 insertions(+), 16 deletions(-) diff --git a/plugins.json b/plugins.json index f8fe62a..233fe86 100644 --- a/plugins.json +++ b/plugins.json @@ -299,7 +299,7 @@ "last_updated": "2026-02-13", "verified": true, "screenshot": "", - "latest_version": "1.1.0" + "latest_version": "1.2.0" }, { "id": "soccer-scoreboard", diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index a67fa5e..c25844f 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -6,9 +6,11 @@ """ import logging +from datetime import datetime from pathlib import Path from typing import Dict, Optional +import pytz from PIL import Image, ImageDraw, ImageFont # Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 @@ -72,8 +74,14 @@ def _load_fonts(self): fonts['rank'] = ImageFont.load_default() return fonts - def _get_logo_path(self, league: str, team_abbrev: str) -> Path: - """Get the logo path for a team based on league.""" + def _get_logo_path(self, league: str, team_abbrev: str, game: Dict = None) -> Path: + """Get the logo path for a team based on league config.""" + # Use league_config logo_dir if available + if game and game.get('league_config'): + logo_dir = game['league_config'].get('logo_dir') + if logo_dir: + return Path(logo_dir) / f"{team_abbrev}.png" + # Fallback to defaults if league == 'mlb': return Path("assets/sports/mlb_logos") / f"{team_abbrev}.png" elif league == 'milb': @@ -83,13 +91,13 @@ def _get_logo_path(self, league: str, team_abbrev: str) -> Path: else: return Path("assets/sports/mlb_logos") / f"{team_abbrev}.png" - def _load_and_resize_logo(self, league: str, team_abbrev: str) -> Optional[Image.Image]: + def _load_and_resize_logo(self, league: str, team_abbrev: str, game: Dict = None) -> Optional[Image.Image]: """Load and resize a team logo, with caching.""" cache_key = f"{league}_{team_abbrev}" if cache_key in self._logo_cache: return self._logo_cache[cache_key] - logo_path = self._get_logo_path(league, team_abbrev) + logo_path = self._get_logo_path(league, team_abbrev, game) if not logo_path.exists(): self.logger.warning(f"Logo not found for {team_abbrev} at {logo_path}") @@ -152,8 +160,8 @@ def _render_live_game(self, game: Dict) -> Image.Image: draw = ImageDraw.Draw(overlay) league = game.get('league', 'mlb') - home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) - away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', ''), game) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', ''), game) if not home_logo or not away_logo: return self._render_error_card("Logo Error") @@ -270,6 +278,10 @@ def _render_live_game(self, game: Dict) -> Image.Image: home_w = len(home_text) * 8 self._draw_text_with_outline(draw, home_text, (self.display_width - home_w - 2, score_y), score_font) + # Odds + if game.get('odds'): + self._draw_dynamic_odds(draw, game['odds']) + main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -285,8 +297,8 @@ def _render_recent_game(self, game: Dict) -> Image.Image: draw = ImageDraw.Draw(overlay) league = game.get('league', 'mlb') - home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) - away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', ''), game) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', ''), game) if not home_logo or not away_logo: return self._render_error_card("Logo Error") @@ -312,6 +324,10 @@ def _render_recent_game(self, game: Dict) -> Image.Image: # Records at bottom corners self._draw_records(draw, game) + # Odds + if game.get('odds'): + self._draw_dynamic_odds(draw, game['odds']) + main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -327,8 +343,8 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: draw = ImageDraw.Draw(overlay) league = game.get('league', 'mlb') - home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) - away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', ''), game) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', ''), game) if not home_logo or not away_logo: return self._render_error_card("Logo Error") @@ -351,14 +367,12 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: game_time = '' if start_time: try: - from datetime import datetime - import pytz dt = datetime.fromisoformat(start_time.replace('Z', '+00:00')) local_tz = pytz.timezone(self.config.get('timezone', 'US/Eastern')) dt_local = dt.astimezone(local_tz) game_date = dt_local.strftime('%b %d') game_time = dt_local.strftime('%-I:%M %p') - except (ValueError, AttributeError, ImportError): + except (ValueError, AttributeError): game_time = start_time[:10] if len(start_time) > 10 else start_time time_font = self.fonts['time'] @@ -374,6 +388,10 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: # Records at bottom corners self._draw_records(draw, game) + # Odds + if game.get('odds'): + self._draw_dynamic_odds(draw, game['odds']) + main_img = Image.alpha_composite(main_img, overlay) return main_img.convert("RGB") @@ -407,6 +425,66 @@ def _draw_records(self, draw, game: Dict): home_w = home_bbox[2] - home_bbox[0] self._draw_text_with_outline(draw, home_record, (self.display_width - home_w, record_y), record_font) + def _draw_dynamic_odds(self, draw, odds: Dict) -> None: + """Draw odds with dynamic positioning based on favored team.""" + try: + if not odds: + return + + home_team_odds = odds.get('home_team_odds', {}) + away_team_odds = odds.get('away_team_odds', {}) + home_spread = home_team_odds.get('spread_odds') + away_spread = away_team_odds.get('spread_odds') + + # Get top-level spread as fallback + top_level_spread = odds.get('spread') + if top_level_spread is not None: + if home_spread is None or home_spread == 0.0: + home_spread = top_level_spread + if away_spread is None: + away_spread = -top_level_spread + + # Determine favored team + home_favored = isinstance(home_spread, (int, float)) and home_spread < 0 + away_favored = isinstance(away_spread, (int, float)) and away_spread < 0 + + favored_spread = None + favored_side = None + + if home_favored: + favored_spread = home_spread + favored_side = 'home' + elif away_favored: + favored_spread = away_spread + favored_side = 'away' + + # Show the negative spread on the appropriate side + font = self.fonts['detail'] + if favored_spread is not None: + spread_text = str(favored_spread) + spread_width = draw.textlength(spread_text, font=font) + if favored_side == 'home': + spread_x = self.display_width - spread_width + else: + spread_x = 0 + self._draw_text_with_outline(draw, spread_text, (spread_x, 0), font, fill=(0, 255, 0)) + + # Show over/under on opposite side + over_under = odds.get('over_under') + if over_under is not None and isinstance(over_under, (int, float)): + ou_text = f"O/U: {over_under}" + ou_width = draw.textlength(ou_text, font=font) + if favored_side == 'home': + ou_x = 0 + elif favored_side == 'away': + ou_x = self.display_width - ou_width + else: + ou_x = (self.display_width - ou_width) // 2 + self._draw_text_with_outline(draw, ou_text, (ou_x, 0), font, fill=(0, 255, 0)) + + except Exception as e: + self.logger.error(f"Error drawing odds: {e}") + def _render_error_card(self, message: str) -> Image.Image: """Render an error message card.""" img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index bb1d6ba..06571cb 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -54,6 +54,12 @@ except ImportError: ScrollDisplayManager = None SCROLL_AVAILABLE = False + +# Import odds manager +try: + from odds_manager import BaseballOddsManager +except ImportError: + BaseballOddsManager = None logger = logging.getLogger(__name__) @@ -190,6 +196,15 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], else: self.logger.info("Scroll display not available - scroll mode disabled") + # Initialize odds manager + self._odds_manager = None + if BaseballOddsManager: + try: + self._odds_manager = BaseballOddsManager(cache_manager, logger=self.logger) + self.logger.info("Baseball odds manager initialized") + except Exception as e: + self.logger.warning(f"Could not initialize odds manager: {e}") + self.initialized = True # Initialize data manager for background fetching @@ -883,6 +898,22 @@ def _draw_records(self, draw: ImageDraw.Draw, game: Dict, width: int, height: in home_w = home_bbox[2] - home_bbox[0] self._draw_text_with_outline(draw, home_text, (width - home_w, record_y), record_font) + def _fetch_and_render_odds(self, draw: ImageDraw.Draw, game: Dict, width: int, height: int): + """Fetch and render odds for a game if enabled.""" + if not self._odds_manager: + return + + league_config = game.get('league_config', {}) + league = game.get('league', 'mlb') + show_odds = league_config.get('show_odds', self.config.get('show_odds', False)) + if not show_odds: + return + + self._odds_manager.fetch_odds(game, league_config, 'baseball', league) + odds = game.get('odds') + if odds: + self._odds_manager.render_odds(draw, odds, width, height, self.fonts) + def _display_live_game(self, game: Dict): """Display a live baseball game with full scorebug: bases, outs, count, inning.""" matrix_width = self.display_manager.matrix.width @@ -1030,6 +1061,9 @@ def _display_live_game(self, game: Dict): home_text_width = len(home_text) * 8 self._draw_text_with_outline(draw, home_text, (matrix_width - home_text_width - 2, score_y), score_font) + # Odds + self._fetch_and_render_odds(draw, game, matrix_width, matrix_height) + # Composite and display final_img = Image.alpha_composite(main_img, overlay) self.display_manager.image = final_img.convert('RGB').copy() @@ -1082,6 +1116,9 @@ def _display_recent_game(self, game: Dict): series_y = (matrix_height - series_height) // 2 self._draw_text_with_outline(draw, series_summary, (series_x, series_y), series_font) + # Odds + self._fetch_and_render_odds(draw, game, matrix_width, matrix_height) + # Composite and display final_img = Image.alpha_composite(main_img, overlay) self.display_manager.image = final_img.convert('RGB').copy() @@ -1144,6 +1181,9 @@ def _display_upcoming_game(self, game: Dict): # Records at bottom corners self._draw_records(draw, game, matrix_width, matrix_height) + # Odds + self._fetch_and_render_odds(draw, game, matrix_width, matrix_height) + # Composite and display final_img = Image.alpha_composite(main_img, overlay) self.display_manager.image = final_img.convert('RGB').copy() diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 3535d90..e25ca86 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.1.0", + "version": "1.2.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -24,6 +24,11 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-02-14", + "version": "1.2.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-02-13", "version": "1.1.0", @@ -50,7 +55,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-02-13", + "last_updated": "2026-02-14", "stars": 0, "downloads": 0, "verified": true, From b757986fa95dd837d198b1d370f12c4d66cd6653 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 13 Feb 2026 22:12:09 -0500 Subject: [PATCH 04/15] feat(baseball): integrate logo manager and rankings manager - Wire up BaseballLogoManager for auto-download of missing logos via ESPN API, with fallback to inline logo loading when unavailable - Wire up BaseballRankingsManager to fetch real team rankings (AP Top 25 etc.) from ESPN standings API, cached for 1 hour - Update _draw_records in both manager.py and game_renderer.py to show "#rank" (e.g., "#5") when show_ranking is enabled, matching the football/basketball pattern - Add _get_team_display_text helper for consistent ranking/record display logic across switch and scroll modes - Pass rankings cache through scroll_display.py to GameRenderer via set_rankings_cache() for scroll mode support - Version bump to 1.3.0 Co-Authored-By: Claude Opus 4.6 --- plugins.json | 2 +- plugins/baseball-scoreboard/game_renderer.py | 47 ++++-- plugins/baseball-scoreboard/manager.py | 139 +++++++++++++++--- plugins/baseball-scoreboard/manifest.json | 7 +- plugins/baseball-scoreboard/scroll_display.py | 7 +- 5 files changed, 165 insertions(+), 37 deletions(-) diff --git a/plugins.json b/plugins.json index 233fe86..cd8bcde 100644 --- a/plugins.json +++ b/plugins.json @@ -299,7 +299,7 @@ "last_updated": "2026-02-13", "verified": true, "screenshot": "", - "latest_version": "1.2.0" + "latest_version": "1.3.0" }, { "id": "soccer-scoreboard", diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index c25844f..a2f1564 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -50,6 +50,9 @@ def __init__( # Use provided logo cache or create new one self._logo_cache = logo_cache if logo_cache is not None else {} + # Rankings cache (populated externally via set_rankings_cache) + self._team_rankings_cache: Dict[str, int] = {} + # Load fonts self.fonts = self._load_fonts() @@ -131,6 +134,10 @@ def _draw_text_with_outline(self, draw, text, position, font, draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) + def set_rankings_cache(self, rankings: Dict[str, int]) -> None: + """Set the team rankings cache for display.""" + self._team_rankings_cache = rankings + def render_game_card(self, game: Dict, game_type: str) -> Image.Image: """ Render a game card as a PIL Image. @@ -399,8 +406,20 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: self.logger.exception("Error rendering upcoming game") return self._render_error_card("Display error") + def _get_team_display_text(self, abbr: str, record: str, show_records: bool, show_ranking: bool) -> str: + """Get display text for a team (ranking or record).""" + if show_ranking: + rank = self._team_rankings_cache.get(abbr, 0) + if rank > 0: + return f"#{rank}" + if not show_records: + return '' + if show_records: + return record + return '' + def _draw_records(self, draw, game: Dict): - """Draw team records at bottom corners if enabled by config.""" + """Draw team records or rankings at bottom corners if enabled by config.""" league_config = game.get('league_config', {}) show_records = league_config.get('show_records', self.config.get('show_records', False)) show_ranking = league_config.get('show_ranking', self.config.get('show_ranking', False)) @@ -408,22 +427,28 @@ def _draw_records(self, draw, game: Dict): if not show_records and not show_ranking: return - away_record = game.get('away_record', '') - home_record = game.get('home_record', '') - if not away_record and not home_record: - return - record_font = self.fonts['detail'] record_bbox = draw.textbbox((0, 0), "0-0", font=record_font) record_height = record_bbox[3] - record_bbox[1] record_y = self.display_height - record_height - if away_record: - self._draw_text_with_outline(draw, away_record, (0, record_y), record_font) - if home_record: - home_bbox = draw.textbbox((0, 0), home_record, font=record_font) + # Away team (bottom left) + away_text = self._get_team_display_text( + game.get('away_abbr', ''), game.get('away_record', ''), + show_records, show_ranking + ) + if away_text: + self._draw_text_with_outline(draw, away_text, (0, record_y), record_font) + + # Home team (bottom right) + home_text = self._get_team_display_text( + game.get('home_abbr', ''), game.get('home_record', ''), + show_records, show_ranking + ) + if home_text: + home_bbox = draw.textbbox((0, 0), home_text, font=record_font) home_w = home_bbox[2] - home_bbox[0] - self._draw_text_with_outline(draw, home_record, (self.display_width - home_w, record_y), record_font) + self._draw_text_with_outline(draw, home_text, (self.display_width - home_w, record_y), record_font) def _draw_dynamic_odds(self, draw, odds: Dict) -> None: """Draw odds with dynamic positioning based on favored team.""" diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 06571cb..d8f9069 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -60,6 +60,19 @@ from odds_manager import BaseballOddsManager except ImportError: BaseballOddsManager = None + +# Import logo manager for auto-download support +try: + from logo_manager import BaseballLogoManager +except ImportError: + BaseballLogoManager = None + +# Import rankings manager +try: + from rankings_manager import BaseballRankingsManager +except ImportError: + BaseballRankingsManager = None + logger = logging.getLogger(__name__) @@ -205,6 +218,25 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], except Exception as e: self.logger.warning(f"Could not initialize odds manager: {e}") + # Initialize logo manager for auto-download support + self._logo_manager = None + if BaseballLogoManager: + try: + self._logo_manager = BaseballLogoManager(display_manager, self.logger) + self.logger.info("Baseball logo manager initialized") + except Exception as e: + self.logger.warning(f"Could not initialize logo manager: {e}") + + # Initialize rankings manager + self._rankings_manager = None + self._team_rankings_cache: Dict[str, int] = {} + if BaseballRankingsManager: + try: + self._rankings_manager = BaseballRankingsManager(self.logger) + self.logger.info("Baseball rankings manager initialized") + except Exception as e: + self.logger.warning(f"Could not initialize rankings manager: {e}") + self.initialized = True # Initialize data manager for background fetching @@ -349,6 +381,10 @@ def update(self) -> None: game['league_config'] = league_config new_games.extend(games) + # Fetch rankings if enabled + if self.show_ranking and self._rankings_manager: + self._fetch_all_rankings() + # Update shared state under lock (protected by lock for thread safety) with self._games_lock: self.current_games = new_games @@ -377,6 +413,32 @@ def sort_key(game): self.current_games.sort(key=sort_key) + def _fetch_all_rankings(self): + """Fetch team rankings for all enabled leagues that support rankings.""" + if not self._rankings_manager: + return + + # ESPN league identifiers for rankings API + league_mappings = { + 'mlb': ('baseball', 'mlb'), + 'ncaa_baseball': ('baseball', 'college-baseball'), + } + + for league_key, league_config in self.leagues.items(): + if not league_config.get('enabled', False): + continue + if league_key not in league_mappings: + continue + + sport, league_id = league_mappings[league_key] + rankings = self._rankings_manager.fetch_rankings(sport, league_id, league_key) + if rankings: + self._team_rankings_cache.update(rankings) + + def _get_rankings_cache(self) -> Dict[str, int]: + """Get the combined team rankings cache.""" + return self._team_rankings_cache + def _fetch_league_data(self, league_key: str, league_config: Dict) -> List[Dict]: """Fetch game data for a specific league. @@ -764,8 +826,8 @@ def get_live_modes(self) -> list: """ return ['baseball_live'] - def _load_team_logo(self, team_abbrev: str, league: str) -> Optional[Image.Image]: - """Load and resize team logo.""" + def _load_team_logo(self, team_abbrev: str, league: str, game: Dict = None) -> Optional[Image.Image]: + """Load and resize team logo, with auto-download via logo manager if available.""" try: if not team_abbrev: return None @@ -788,25 +850,46 @@ def _load_team_logo(self, team_abbrev: str, league: str) -> Optional[Image.Image else: logo_dir = os.path.abspath(logo_dir) - # Try different case variations and extensions + logo_path = Path(logo_dir) / f"{team_abbrev}.png" + + # Use logo manager if available (supports auto-download of missing logos) + if self._logo_manager: + team_id = '' + logo_url = None + if game: + side = 'home' if game.get('home_abbr') == team_abbrev else 'away' + team_id = game.get(f'{side}_id', '') + logo_url = game.get(f'{side}_logo_url') + + if league == 'milb': + logo = self._logo_manager.load_milb_logo(team_abbrev, Path(logo_dir)) + else: + sport_key = 'college-baseball' if league == 'ncaa_baseball' else 'baseball' + logo = self._logo_manager.load_logo( + team_id, team_abbrev, logo_path, + logo_url=logo_url, sport_key=sport_key + ) + if logo: + return logo + + # Fallback: inline logo loading (no auto-download) logo_extensions = ['.png', '.jpg', '.jpeg'] - logo_path = None + found_path = None abbrev_variations = [team_abbrev.upper(), team_abbrev.lower(), team_abbrev] for abbrev in abbrev_variations: for ext in logo_extensions: potential_path = os.path.join(logo_dir, f"{abbrev}{ext}") if os.path.exists(potential_path): - logo_path = potential_path + found_path = potential_path break - if logo_path: + if found_path: break - if not logo_path: + if not found_path: return None - # Load and resize logo (matching original managers) - logo = Image.open(logo_path).convert('RGBA') + logo = Image.open(found_path).convert('RGBA') max_width = int(self.display_manager.matrix.width * 1.5) max_height = int(self.display_manager.matrix.height * 1.5) logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) @@ -847,8 +930,8 @@ def _display_game(self, game: Dict, mode: str): def _paste_logos(self, main_img: Image.Image, game: Dict, inward_offset: int = 10): """Load and paste team logos onto the image. Returns (home_logo, away_logo) or (None, None).""" league = game.get('league', '') - home_logo = self._load_team_logo(game.get('home_abbr', ''), league) - away_logo = self._load_team_logo(game.get('away_abbr', ''), league) + home_logo = self._load_team_logo(game.get('home_abbr', ''), league, game) + away_logo = self._load_team_logo(game.get('away_abbr', ''), league, game) if not home_logo or not away_logo: return None, None @@ -864,6 +947,19 @@ def _paste_logos(self, main_img: Image.Image, game: Dict, inward_offset: int = 1 return home_logo, away_logo + def _get_team_display_text(self, abbr: str, record: str, show_records: bool, show_ranking: bool) -> str: + """Get display text for a team (ranking or record), matching football/basketball pattern.""" + if show_ranking: + rank = self._team_rankings_cache.get(abbr, 0) + if rank > 0: + return f"#{rank}" + # Fall through to records if unranked + if not show_records: + return '' + if show_records: + return record + return '' + def _draw_records(self, draw: ImageDraw.Draw, game: Dict, width: int, height: int): """Draw team records or rankings at bottom corners if enabled.""" league_config = game.get('league_config', {}) @@ -879,20 +975,18 @@ def _draw_records(self, draw: ImageDraw.Draw, game: Dict, width: int, height: in record_y = height - record_height # Away team (bottom left) - away_text = '' - if show_ranking: - away_text = game.get('away_record', '') # rankings would replace this if available - elif show_records: - away_text = game.get('away_record', '') + away_text = self._get_team_display_text( + game.get('away_abbr', ''), game.get('away_record', ''), + show_records, show_ranking + ) if away_text: self._draw_text_with_outline(draw, away_text, (0, record_y), record_font) # Home team (bottom right) - home_text = '' - if show_ranking: - home_text = game.get('home_record', '') - elif show_records: - home_text = game.get('home_record', '') + home_text = self._get_team_display_text( + game.get('home_abbr', ''), game.get('home_record', ''), + show_records, show_ranking + ) if home_text: home_bbox = draw.textbbox((0, 0), home_text, font=record_font) home_w = home_bbox[2] - home_bbox[0] @@ -1438,8 +1532,9 @@ def _ensure_scroll_content_for_vegas(self) -> None: # Prepare scroll content with mixed game types # Note: Using 'mixed' as game_type indicator for scroll config + rankings = self._get_rankings_cache() if self.show_ranking else None success = self._scroll_manager.prepare_and_display( - games, 'mixed', leagues, None + games, 'mixed', leagues, rankings ) if success: diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index e25ca86..5ef9931 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.2.0", + "version": "1.3.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -24,6 +24,11 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-02-14", + "version": "1.3.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-02-14", "version": "1.2.0", diff --git a/plugins/baseball-scoreboard/scroll_display.py b/plugins/baseball-scoreboard/scroll_display.py index 7f93fd9..90e782a 100644 --- a/plugins/baseball-scoreboard/scroll_display.py +++ b/plugins/baseball-scoreboard/scroll_display.py @@ -303,8 +303,7 @@ def prepare_scroll_content( games: List of game dictionaries with league info game_type: Type hint ('live', 'recent', 'upcoming', or 'mixed' for mixed types) leagues: List of leagues in order (e.g., ['mlb', 'milb', 'ncaa_baseball']) - rankings_cache: Optional team rankings cache. Kept for API compatibility with - other sports plugins but not used for baseball (baseball doesn't show rankings). + rankings_cache: Optional team rankings cache for displaying team rankings Returns: True if content was prepared successfully, False otherwise @@ -332,6 +331,10 @@ def prepare_scroll_content( # Get or create cached game renderer renderer = self._get_game_renderer() + # Pass rankings cache to renderer if available + if renderer and rankings_cache: + renderer.set_rankings_cache(rankings_cache) + # Pre-render all game cards content_items: List[Image.Image] = [] current_league = None From aaff818ba071a8ea6eb57a9d1b24a1117951810f Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 13 Feb 2026 22:17:27 -0500 Subject: [PATCH 05/15] fix(baseball): move odds fetch to update cycle, fix pick'em line and odds overlap Move blocking odds fetch from render path (_fetch_and_render_odds) to update() cycle so network I/O doesn't occur during display rendering. Fix pick'em line bug where home_spread == 0.0 was treated as missing data in both game_renderer.py and odds_manager.py. Fix odds y-position in game_renderer.py to render below the status row instead of at y=0. Co-Authored-By: Claude Opus 4.6 --- plugins.json | 2 +- plugins/baseball-scoreboard/game_renderer.py | 16 ++++++++++------ plugins/baseball-scoreboard/manager.py | 16 +++++++++++++--- plugins/baseball-scoreboard/odds_manager.py | 4 ++-- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/plugins.json b/plugins.json index cd8bcde..2c5a38b 100644 --- a/plugins.json +++ b/plugins.json @@ -296,7 +296,7 @@ "plugin_path": "plugins/baseball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-13", + "last_updated": "2026-02-14", "verified": true, "screenshot": "", "latest_version": "1.3.0" diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index a2f1564..b0bf182 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -461,10 +461,10 @@ def _draw_dynamic_odds(self, draw, odds: Dict) -> None: home_spread = home_team_odds.get('spread_odds') away_spread = away_team_odds.get('spread_odds') - # Get top-level spread as fallback + # Get top-level spread as fallback (only when individual spread is truly missing) top_level_spread = odds.get('spread') if top_level_spread is not None: - if home_spread is None or home_spread == 0.0: + if home_spread is None: home_spread = top_level_spread if away_spread is None: away_spread = -top_level_spread @@ -483,6 +483,10 @@ def _draw_dynamic_odds(self, draw, odds: Dict) -> None: favored_spread = away_spread favored_side = 'away' + # Odds row below the status/inning text row + status_bbox = draw.textbbox((0, 0), "A", font=self.fonts['time']) + odds_y = status_bbox[3] + 2 # just below the status row + # Show the negative spread on the appropriate side font = self.fonts['detail'] if favored_spread is not None: @@ -492,7 +496,7 @@ def _draw_dynamic_odds(self, draw, odds: Dict) -> None: spread_x = self.display_width - spread_width else: spread_x = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, 0), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, spread_text, (spread_x, odds_y), font, fill=(0, 255, 0)) # Show over/under on opposite side over_under = odds.get('over_under') @@ -505,10 +509,10 @@ def _draw_dynamic_odds(self, draw, odds: Dict) -> None: ou_x = self.display_width - ou_width else: ou_x = (self.display_width - ou_width) // 2 - self._draw_text_with_outline(draw, ou_text, (ou_x, 0), font, fill=(0, 255, 0)) + self._draw_text_with_outline(draw, ou_text, (ou_x, odds_y), font, fill=(0, 255, 0)) - except Exception as e: - self.logger.error(f"Error drawing odds: {e}") + except Exception: + self.logger.exception("Error drawing odds") def _render_error_card(self, message: str) -> Image.Image: """Render an error message card.""" diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index d8f9069..93ef1f7 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -381,6 +381,15 @@ def update(self) -> None: game['league_config'] = league_config new_games.extend(games) + # Fetch odds for each game if enabled + if self._odds_manager: + for game in new_games: + league_config = game.get('league_config', {}) + league = game.get('league', 'mlb') + show_odds = league_config.get('show_odds', self.config.get('show_odds', False)) + if show_odds: + self._odds_manager.fetch_odds(game, league_config, 'baseball', league) + # Fetch rankings if enabled if self.show_ranking and self._rankings_manager: self._fetch_all_rankings() @@ -993,17 +1002,18 @@ def _draw_records(self, draw: ImageDraw.Draw, game: Dict, width: int, height: in self._draw_text_with_outline(draw, home_text, (width - home_w, record_y), record_font) def _fetch_and_render_odds(self, draw: ImageDraw.Draw, game: Dict, width: int, height: int): - """Fetch and render odds for a game if enabled.""" + """Render pre-fetched odds for a game if enabled. + + Odds data is populated during update() cycle — this method only renders. + """ if not self._odds_manager: return league_config = game.get('league_config', {}) - league = game.get('league', 'mlb') show_odds = league_config.get('show_odds', self.config.get('show_odds', False)) if not show_odds: return - self._odds_manager.fetch_odds(game, league_config, 'baseball', league) odds = game.get('odds') if odds: self._odds_manager.render_odds(draw, odds, width, height, self.fonts) diff --git a/plugins/baseball-scoreboard/odds_manager.py b/plugins/baseball-scoreboard/odds_manager.py index 0e920b6..55959ed 100644 --- a/plugins/baseball-scoreboard/odds_manager.py +++ b/plugins/baseball-scoreboard/odds_manager.py @@ -120,9 +120,9 @@ def render_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, # Get top-level spread as fallback top_level_spread = odds.get('spread') - # If we have a top-level spread and the individual spreads are None or 0, use the top-level + # If we have a top-level spread and the individual spreads are missing, use the top-level if top_level_spread is not None: - if home_spread is None or home_spread == 0.0: + if home_spread is None: home_spread = top_level_spread if away_spread is None: away_spread = -top_level_spread From aaa4a17ded9624317a5ede1581399953557a719f Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 13 Feb 2026 22:24:03 -0500 Subject: [PATCH 06/15] fix(baseball): thread-safe rankings, close logo file handles, Pillow compat Use atomic swap under _games_lock for _team_rankings_cache so display threads always see a consistent snapshot. Close Image.open file handles in logo fallback path and logo_manager.py by using context managers. Add RESAMPLE_FILTER compatibility shim for Pillow < 9.1 in both manager.py and logo_manager.py. Co-Authored-By: Claude Opus 4.6 --- plugins/baseball-scoreboard/logo_manager.py | 24 ++++++++++----------- plugins/baseball-scoreboard/manager.py | 23 ++++++++++++++++---- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/plugins/baseball-scoreboard/logo_manager.py b/plugins/baseball-scoreboard/logo_manager.py index dc5828b..5f49993 100644 --- a/plugins/baseball-scoreboard/logo_manager.py +++ b/plugins/baseball-scoreboard/logo_manager.py @@ -11,6 +11,12 @@ from PIL import Image +# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 +try: + RESAMPLE_FILTER = Image.Resampling.LANCZOS +except AttributeError: + RESAMPLE_FILTER = Image.LANCZOS + try: from src.logo_downloader import LogoDownloader, download_missing_logo except ImportError: @@ -105,19 +111,16 @@ def load_logo(self, team_id: str, team_abbr: str, logo_path: Path, # Only try to open the logo if the file exists if os.path.exists(actual_logo_path): - logo = Image.open(actual_logo_path) + with Image.open(actual_logo_path) as src: + logo = src.convert('RGBA') else: self.logger.error(f"Logo file still doesn't exist at {actual_logo_path} after download attempt") return None - # Ensure RGBA mode - if logo.mode != 'RGBA': - logo = logo.convert('RGBA') - # Resize to fit display (130% of display dimensions to allow extending off screen) max_width = int(self.display_width * 1.5) max_height = int(self.display_height * 1.5) - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + logo.thumbnail((max_width, max_height), RESAMPLE_FILTER) # Cache the logo self._logo_cache[team_abbr] = logo @@ -149,19 +152,16 @@ def load_milb_logo(self, team_abbr: str, logo_dir: Path) -> Optional[Image.Image logo_path = logo_dir / f"{team_abbr}.png" if logo_path.exists(): - logo = Image.open(logo_path) + with Image.open(logo_path) as src: + logo = src.convert('RGBA') else: self.logger.warning(f"MiLB logo not found for {team_abbr} at {logo_path}") return None - # Ensure RGBA mode - if logo.mode != 'RGBA': - logo = logo.convert('RGBA') - # Resize to fit display (130% of display dimensions) max_width = int(self.display_width * 1.5) max_height = int(self.display_height * 1.5) - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + logo.thumbnail((max_width, max_height), RESAMPLE_FILTER) # Cache the logo self._logo_cache[team_abbr] = logo diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 93ef1f7..b5f1c6b 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -27,6 +27,12 @@ import requests from PIL import Image, ImageDraw, ImageFont +# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 +try: + RESAMPLE_FILTER = Image.Resampling.LANCZOS +except AttributeError: + RESAMPLE_FILTER = Image.LANCZOS + from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode # Import baseball base classes from LEDMatrix @@ -423,7 +429,10 @@ def sort_key(game): self.current_games.sort(key=sort_key) def _fetch_all_rankings(self): - """Fetch team rankings for all enabled leagues that support rankings.""" + """Fetch team rankings for all enabled leagues that support rankings. + + Uses atomic swap to avoid concurrent read/write issues with display threads. + """ if not self._rankings_manager: return @@ -433,6 +442,7 @@ def _fetch_all_rankings(self): 'ncaa_baseball': ('baseball', 'college-baseball'), } + new_cache: Dict[str, int] = {} for league_key, league_config in self.leagues.items(): if not league_config.get('enabled', False): continue @@ -442,7 +452,11 @@ def _fetch_all_rankings(self): sport, league_id = league_mappings[league_key] rankings = self._rankings_manager.fetch_rankings(sport, league_id, league_key) if rankings: - self._team_rankings_cache.update(rankings) + new_cache.update(rankings) + + # Atomic swap under lock so display threads see a consistent snapshot + with self._games_lock: + self._team_rankings_cache = new_cache def _get_rankings_cache(self) -> Dict[str, int]: """Get the combined team rankings cache.""" @@ -898,10 +912,11 @@ def _load_team_logo(self, team_abbrev: str, league: str, game: Dict = None) -> O if not found_path: return None - logo = Image.open(found_path).convert('RGBA') + with Image.open(found_path) as src: + logo = src.convert('RGBA') max_width = int(self.display_manager.matrix.width * 1.5) max_height = int(self.display_manager.matrix.height * 1.5) - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + logo.thumbnail((max_width, max_height), RESAMPLE_FILTER) return logo From cb3788475c76acd43bba5252c689635a729ab022 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 14 Feb 2026 19:39:58 -0500 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20sports=20scoreboard=20parity=20?= =?UTF-8?q?=E2=80=94=20SportsCore=20for=20baseball,=20fixes=20across=20all?= =?UTF-8?q?=205=20plugins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1-3: Sync shared files and fix gaps across basketball, football, hockey, soccer - Add stale game detection to soccer (was the only SportsCore plugin missing it) - Add sticky manager logic to football and soccer (prevents mid-display league switching) - Add ScrollDisplayManager to soccer (was missing scroll mode support) - Expand soccer data_sources.py with standings/rankings/schedule fetching - Add mode_durations config to basketball and soccer - Fix cache_manager API calls across all 5 plugins (delete→clear_cache) - Fix hockey statistics .get() safety (prevents KeyError on missing stats) Phase 4: Refactor baseball to SportsCore architecture - Add SportsCore shared files: sports.py, data_sources.py, logo_downloader.py, base_odds_manager.py, dynamic_team_resolver.py - Create per-league managers: mlb_managers.py, milb_managers.py, ncaa_baseball_managers.py - Add baseball.py with baseball-specific extraction (inning, bases, outs, count) - Rewrite milb_managers.py to use MLB Stats API (ESPN MiLB endpoint returns 400) - Restructure config_schema.json from flat to nested per-league format - Rewrite manager.py with league registry, sticky manager, mode durations, dynamic duration, and full MiLB integration - Fix game_renderer.py records config path (was reading dead league_config from game dict) Version bumps: baseball 2.0.0, basketball 1.1.0, football 2.1.0, hockey 1.1.0, soccer 1.3.0 Co-Authored-By: Claude Opus 4.6 --- plugins.json | 20 +- .../baseball-scoreboard/base_odds_manager.py | 293 + plugins/baseball-scoreboard/baseball.py | 713 +++ .../baseball-scoreboard/config_schema.json | 1224 +++-- plugins/baseball-scoreboard/data_sources.py | 307 ++ .../dynamic_team_resolver.py | 179 + plugins/baseball-scoreboard/game_renderer.py | 16 +- .../baseball-scoreboard/logo_downloader.py | 176 + plugins/baseball-scoreboard/manager.py | 4732 ++++++++++++----- plugins/baseball-scoreboard/manifest.json | 16 +- plugins/baseball-scoreboard/milb_managers.py | 386 ++ plugins/baseball-scoreboard/mlb_managers.py | 227 + .../ncaa_baseball_managers.py | 236 + plugins/baseball-scoreboard/sports.py | 2485 +++++++++ .../base_odds_manager.py | 4 +- plugins/basketball-scoreboard/manager.py | 6 +- plugins/basketball-scoreboard/manifest.json | 7 +- plugins/basketball-scoreboard/sports.py | 163 +- .../football-scoreboard/base_odds_manager.py | 4 +- plugins/football-scoreboard/manifest.json | 10 +- plugins/hockey-scoreboard/base_classes.py | 6 +- .../hockey-scoreboard/base_odds_manager.py | 4 +- plugins/hockey-scoreboard/hockey.py | 10 +- plugins/hockey-scoreboard/manager.py | 4 + plugins/hockey-scoreboard/manifest.json | 7 +- plugins/hockey-scoreboard/sports.py | 168 +- .../soccer-scoreboard/base_odds_manager.py | 14 +- plugins/soccer-scoreboard/config_schema.json | 497 +- plugins/soccer-scoreboard/data_sources.py | 247 +- plugins/soccer-scoreboard/manager.py | 164 +- plugins/soccer-scoreboard/manifest.json | 7 +- plugins/soccer-scoreboard/scroll_display.py | 179 + plugins/soccer-scoreboard/sports.py | 132 +- 33 files changed, 10820 insertions(+), 1823 deletions(-) create mode 100644 plugins/baseball-scoreboard/base_odds_manager.py create mode 100644 plugins/baseball-scoreboard/baseball.py create mode 100644 plugins/baseball-scoreboard/data_sources.py create mode 100644 plugins/baseball-scoreboard/dynamic_team_resolver.py create mode 100644 plugins/baseball-scoreboard/logo_downloader.py create mode 100644 plugins/baseball-scoreboard/milb_managers.py create mode 100644 plugins/baseball-scoreboard/mlb_managers.py create mode 100644 plugins/baseball-scoreboard/ncaa_baseball_managers.py create mode 100644 plugins/baseball-scoreboard/sports.py diff --git a/plugins.json b/plugins.json index 2c5a38b..1994725 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-02-13", + "last_updated": "2026-02-14", "plugins": [ { "id": "hello-world", @@ -196,10 +196,10 @@ "plugin_path": "plugins/hockey-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-11", + "last_updated": "2026-02-14", "verified": true, "screenshot": "", - "latest_version": "1.0.8" + "latest_version": "1.1.0" }, { "id": "football-scoreboard", @@ -221,10 +221,10 @@ "plugin_path": "plugins/football-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2025-11-05", + "last_updated": "2026-02-14", "verified": true, "screenshot": "", - "latest_version": "2.0.7" + "latest_version": "2.1.0" }, { "id": "ufc-scoreboard", @@ -270,10 +270,10 @@ "plugin_path": "plugins/basketball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2025-10-26", + "last_updated": "2026-02-14", "verified": true, "screenshot": "", - "latest_version": "1.0.5" + "latest_version": "1.1.0" }, { "id": "baseball-scoreboard", @@ -299,7 +299,7 @@ "last_updated": "2026-02-14", "verified": true, "screenshot": "", - "latest_version": "1.3.0" + "latest_version": "2.0.0" }, { "id": "soccer-scoreboard", @@ -325,10 +325,10 @@ "plugin_path": "plugins/soccer-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-11", + "last_updated": "2026-02-14", "verified": true, "screenshot": "", - "latest_version": "1.1.0" + "latest_version": "1.3.0" }, { "id": "odds-ticker", diff --git a/plugins/baseball-scoreboard/base_odds_manager.py b/plugins/baseball-scoreboard/base_odds_manager.py new file mode 100644 index 0000000..6763e70 --- /dev/null +++ b/plugins/baseball-scoreboard/base_odds_manager.py @@ -0,0 +1,293 @@ +""" +BaseOddsManager - Base class for odds data fetching and management. + +This base class provides core odds fetching functionality that can be inherited +by plugins that need odds data (odds ticker, scoreboards, etc.). + +Follows LEDMatrix configuration management patterns: +- Single responsibility: Data fetching only +- Reusable: Other plugins can inherit from it +- Clean configuration: Separate config sections +- Maintainable: Changes to odds logic affect all plugins +""" + +import time +import logging +import requests +import json +from datetime import datetime, timedelta, timezone +from typing import Dict, Any, Optional, List +import pytz + +# Import the API counter function from web interface +try: + from web_interface_v2 import increment_api_counter +except ImportError: + # Fallback if web interface is not available + def increment_api_counter(kind: str, count: int = 1): + pass + + +class BaseOddsManager: + """ + Base class for odds data fetching and management. + + Provides core functionality for: + - ESPN API odds fetching + - Caching and data processing + - Error handling and timeouts + - League mapping and data extraction + + Plugins can inherit from this class to get odds functionality. + """ + + def __init__(self, cache_manager, config_manager=None): + """ + Initialize the base odds manager. + + Args: + cache_manager: Cache manager instance for data persistence + config_manager: Configuration manager (optional) + """ + self.cache_manager = cache_manager + self.config_manager = config_manager + self.logger = logging.getLogger(__name__) + self.base_url = "https://sports.core.api.espn.com/v2/sports" + + # Configuration with defaults + self.update_interval = 3600 # 1 hour default + self.request_timeout = 30 # 30 seconds default + self.cache_ttl = 1800 # 30 minutes default + + # Load configuration if available + if config_manager: + self._load_configuration() + + def _load_configuration(self): + """Load configuration from config manager.""" + if not self.config_manager: + return + + try: + config = self.config_manager.get_config() + odds_config = config.get("base_odds_manager", {}) + + self.update_interval = odds_config.get( + "update_interval", self.update_interval + ) + self.request_timeout = odds_config.get("timeout", self.request_timeout) + self.cache_ttl = odds_config.get("cache_ttl", self.cache_ttl) + + self.logger.debug( + f"BaseOddsManager configuration loaded: " + f"update_interval={self.update_interval}s, " + f"timeout={self.request_timeout}s, " + f"cache_ttl={self.cache_ttl}s" + ) + + except Exception as e: + self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}") + + def get_odds( + self, + sport: str | None, + league: str | None, + event_id: str, + update_interval_seconds: int = None, + ) -> Optional[Dict[str, Any]]: + """ + Fetch odds data for a specific game. + + Args: + sport: Sport name (e.g., 'football', 'basketball') + league: League name (e.g., 'nfl', 'nba') + event_id: ESPN event ID + update_interval_seconds: Override default update interval + + Returns: + Dictionary containing odds data or None if unavailable + """ + if sport is None or league is None: + raise ValueError("Sport and League cannot be None") + + # Use provided interval or default + interval = update_interval_seconds or self.update_interval + cache_key = f"odds_espn_{sport}_{league}_{event_id}" + + # Check cache first + cached_data = self.cache_manager.get(cache_key) + + if cached_data: + # Filter out the "no_odds" marker – it should not be returned + # as valid odds data. Treat it as a cache miss so a fresh API + # call is made once the cache entry expires. + if isinstance(cached_data, dict) and cached_data.get("no_odds"): + self.logger.debug(f"Cached no-odds marker for {cache_key}, skipping") + else: + self.logger.info(f"Using cached odds from ESPN for {cache_key}") + return cached_data + + self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}") + + try: + # Map league names to ESPN API format + league_mapping = { + "mlb": "mlb", + "college-baseball": "college-baseball", + "ncaa_fb": "college-football", + "nfl": "nfl", + "nba": "nba", + "nhl": "nhl", + } + + espn_league = league_mapping.get(league, league) + url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds" + self.logger.info(f"Requesting odds from URL: {url}") + + response = requests.get(url, timeout=self.request_timeout) + response.raise_for_status() + raw_data = response.json() + + # Increment API counter for odds data + increment_api_counter("odds", 1) + self.logger.debug( + f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}" + ) + + odds_data = self._extract_espn_data(raw_data) + if odds_data: + self.logger.info(f"Successfully extracted odds data: {odds_data}") + else: + self.logger.debug("No odds data available for this game") + + if odds_data: + self.cache_manager.set(cache_key, odds_data) + self.logger.info(f"Saved odds data to cache for {cache_key}") + else: + self.logger.debug(f"No odds data available for {cache_key}") + # Cache the fact that no odds are available to avoid repeated API calls + self.cache_manager.set(cache_key, {"no_odds": True}) + + return odds_data + + except requests.exceptions.RequestException as e: + self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}") + except json.JSONDecodeError: + self.logger.error( + f"Error decoding JSON response from ESPN API for {cache_key}." + ) + + return self.cache_manager.get(cache_key) + + def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Extract and format odds data from ESPN API response. + + Args: + data: Raw ESPN API response data + + Returns: + Formatted odds data dictionary or None + """ + self.logger.debug(f"Extracting ESPN odds data. Data keys: {list(data.keys())}") + + if "items" in data and data["items"]: + self.logger.debug(f"Found {len(data['items'])} items in odds data") + item = data["items"][0] + self.logger.debug(f"First item keys: {list(item.keys())}") + + # The ESPN API returns odds data directly in the item, not in a providers array + # Extract the odds data directly from the item + extracted_data = { + "details": item.get("details"), + "over_under": item.get("overUnder"), + "spread": item.get("spread"), + "home_team_odds": { + "money_line": item.get("homeTeamOdds", {}).get("moneyLine"), + "spread_odds": item.get("homeTeamOdds", {}) + .get("current", {}) + .get("pointSpread", {}) + .get("value"), + }, + "away_team_odds": { + "money_line": item.get("awayTeamOdds", {}).get("moneyLine"), + "spread_odds": item.get("awayTeamOdds", {}) + .get("current", {}) + .get("pointSpread", {}) + .get("value"), + }, + } + self.logger.debug( + f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}" + ) + return extracted_data + + # Check if this is a valid empty response or an unexpected structure + if ( + "count" in data + and data["count"] == 0 + and "items" in data + and data["items"] == [] + ): + # This is a valid empty response - no odds available for this game + self.logger.debug("Valid empty response - no odds available for this game") + return None + + # Unexpected structure + self.logger.warning( + f"Unexpected odds data structure: {json.dumps(data, indent=2)}" + ) + return None + + def get_multiple_odds( + self, + sport: str, + league: str, + event_ids: List[str], + update_interval_seconds: int = None, + ) -> Dict[str, Dict[str, Any]]: + """ + Fetch odds data for multiple games. + + Args: + sport: Sport name + league: League name + event_ids: List of ESPN event IDs + update_interval_seconds: Override default update interval + + Returns: + Dictionary mapping event_id to odds data + """ + results = {} + + for event_id in event_ids: + try: + odds_data = self.get_odds( + sport, league, event_id, update_interval_seconds + ) + if odds_data: + results[event_id] = odds_data + except Exception as e: + self.logger.error(f"Error fetching odds for event {event_id}: {e}") + continue + + return results + + def clear_cache(self, sport: str = None, league: str = None, event_id: str = None): + """ + Clear odds cache for specific criteria. + + Args: + sport: Sport name (optional) + league: League name (optional) + event_id: Event ID (optional) + """ + if sport and league and event_id: + # Clear specific event + cache_key = f"odds_espn_{sport}_{league}_{event_id}" + self.cache_manager.delete(cache_key) + self.logger.info(f"Cleared cache for {cache_key}") + else: + # Clear all odds cache + self.cache_manager.clear() + self.logger.info("Cleared all cache") diff --git a/plugins/baseball-scoreboard/baseball.py b/plugins/baseball-scoreboard/baseball.py new file mode 100644 index 0000000..ad226cd --- /dev/null +++ b/plugins/baseball-scoreboard/baseball.py @@ -0,0 +1,713 @@ +""" +Baseball Base Classes + +This module provides baseball-specific base classes that extend the core sports functionality +with baseball-specific logic for innings, outs, bases, strikes, balls, etc. +""" + +import logging +import time +from typing import Any, Dict, Optional + +from PIL import Image, ImageDraw, ImageFont + +from data_sources import ESPNDataSource +from sports import SportsCore, SportsLive, SportsRecent + + +class Baseball(SportsCore): + """Base class for baseball sports with common functionality.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + # Baseball-specific configuration + self.show_innings = self.mode_config.get("show_innings", True) + self.show_outs = self.mode_config.get("show_outs", True) + self.show_bases = self.mode_config.get("show_bases", True) + self.show_count = self.mode_config.get("show_count", True) + self.show_pitcher_batter = self.mode_config.get("show_pitcher_batter", False) + self.show_series_summary = self.mode_config.get("show_series_summary", False) + self.data_source = ESPNDataSource(logger) + self.sport = "baseball" + + def _get_baseball_display_text(self, game: Dict) -> str: + """Get baseball-specific display text.""" + try: + display_parts = [] + + # Inning information + if self.show_innings: + inning = game.get("inning", "") + if inning: + display_parts.append(f"Inning: {inning}") + + # Outs information + if self.show_outs: + outs = game.get("outs", 0) + if outs is not None: + display_parts.append(f"Outs: {outs}") + + # Bases information + if self.show_bases: + bases = game.get("bases", "") + if bases: + display_parts.append(f"Bases: {bases}") + + # Count information + if self.show_count: + strikes = game.get("strikes", 0) + balls = game.get("balls", 0) + if strikes is not None and balls is not None: + display_parts.append(f"Count: {balls}-{strikes}") + + # Pitcher/Batter information + if self.show_pitcher_batter: + pitcher = game.get("pitcher", "") + batter = game.get("batter", "") + if pitcher: + display_parts.append(f"Pitcher: {pitcher}") + if batter: + display_parts.append(f"Batter: {batter}") + + return " | ".join(display_parts) if display_parts else "" + + except Exception as e: + self.logger.error(f"Error getting baseball display text: {e}") + return "" + + def _is_baseball_game_live(self, game: Dict) -> bool: + """Check if a baseball game is currently live.""" + try: + # Check if game is marked as live + is_live = game.get("is_live", False) + if is_live: + return True + + # Check inning to determine if game is active + inning = game.get("inning", "") + if inning and inning != "Final": + return True + + return False + + except Exception as e: + self.logger.error(f"Error checking if baseball game is live: {e}") + return False + + def _get_baseball_game_status(self, game: Dict) -> str: + """Get baseball-specific game status.""" + try: + status = game.get("status_text", "") + inning = game.get("inning", "") + + if self._is_baseball_game_live(game): + if inning: + return f"Live - {inning}" + else: + return "Live" + elif game.get("is_final", False): + return "Final" + elif game.get("is_upcoming", False): + return "Upcoming" + else: + return status + + except Exception as e: + self.logger.error(f"Error getting baseball game status: {e}") + return "" + + def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: + """Extract relevant game details from ESPN Baseball API response.""" + details, home_team, away_team, status, situation = ( + self._extract_game_details_common(game_event) + ) + if details is None or home_team is None or away_team is None or status is None: + return + try: + game_status = status["type"]["name"].lower() + status_state = status["type"]["state"].lower() + # Get team abbreviations + home_abbr = home_team["team"]["abbreviation"] + away_abbr = away_team["team"]["abbreviation"] + + # Check if this is a favorite team game + is_favorite_game = ( + home_abbr in self.favorite_teams or away_abbr in self.favorite_teams + ) + + # Log all teams found for debugging + self.logger.debug( + f"Found game: {away_abbr} @ {home_abbr} (Status: {game_status}, State: {status_state})" + ) + + # Only log detailed information for favorite teams + if is_favorite_game: + self.logger.debug(f"Full status data: {game_event['status']}") + self.logger.debug(f"Status type: {game_status}, State: {status_state}") + self.logger.debug(f"Status detail: {status['type'].get('detail', '')}") + self.logger.debug( + f"Status shortDetail: {status['type'].get('shortDetail', '')}" + ) + series = game_event["competitions"][0].get("series", None) + series_summary = "" + if series: + series_summary = series.get("summary", "") + # Get game state information + if status_state == "in": + # For live games, get detailed state + inning = game_event["status"].get( + "period", 1 + ) # Get inning from status period + + # Get inning information from status + status_detail = status["type"].get("detail", "").lower() + status_short = status["type"].get("shortDetail", "").lower() + + if is_favorite_game: + self.logger.debug( + f"Raw status detail: {status['type'].get('detail')}" + ) + self.logger.debug( + f"Raw status short: {status['type'].get('shortDetail')}" + ) + + # Determine inning half from status information + inning_half = "top" # Default + + # Handle end of inning: next inning is top + if "end" in status_detail or "end" in status_short: + inning_half = "top" + inning = ( + game_event["status"].get("period", 1) + 1 + ) # Use period and increment for next inning + if is_favorite_game: + self.logger.debug( + f"Detected end of inning. Setting to Top {inning}" + ) + # Handle middle of inning: next is bottom of current inning + elif "mid" in status_detail or "mid" in status_short: + inning_half = "bottom" + if is_favorite_game: + self.logger.debug( + f"Detected middle of inning. Setting to Bottom {inning}" + ) + # Handle bottom of inning + elif ( + "bottom" in status_detail + or "bot" in status_detail + or "bottom" in status_short + or "bot" in status_short + ): + inning_half = "bottom" + if is_favorite_game: + self.logger.debug(f"Detected bottom of inning: {inning}") + # Handle top of inning + elif "top" in status_detail or "top" in status_short: + inning_half = "top" + if is_favorite_game: + self.logger.debug(f"Detected top of inning: {inning}") + + if is_favorite_game: + self.logger.debug(f"Status detail: {status_detail}") + self.logger.debug(f"Status short: {status_short}") + self.logger.debug(f"Determined inning: {inning_half} {inning}") + + # Get count and bases from situation + situation = game_event["competitions"][0].get("situation", {}) + + if is_favorite_game: + self.logger.debug(f"Full situation data: {situation}") + + # Get count from the correct location in the API response + count = situation.get("count", {}) + balls = count.get("balls", 0) + strikes = count.get("strikes", 0) + outs = situation.get("outs", 0) + + # Add detailed logging for favorite team games + if is_favorite_game: + self.logger.debug(f"Full situation data: {situation}") + self.logger.debug(f"Count object: {count}") + self.logger.debug( + f"Raw count values - balls: {balls}, strikes: {strikes}" + ) + self.logger.debug(f"Raw outs value: {outs}") + + # Try alternative locations for count data + if balls == 0 and strikes == 0: + # First try the summary field + if "summary" in situation: + try: + count_summary = situation["summary"] + balls, strikes = map(int, count_summary.split("-")) + if is_favorite_game: + self.logger.debug( + f"Using summary count: {count_summary}" + ) + except (ValueError, AttributeError): + if is_favorite_game: + self.logger.debug("Could not parse summary count") + else: + # Check if count is directly in situation + balls = situation.get("balls", 0) + strikes = situation.get("strikes", 0) + if is_favorite_game: + self.logger.debug( + f"Using direct situation count: balls={balls}, strikes={strikes}" + ) + self.logger.debug( + f"Full situation keys: {list(situation.keys())}" + ) + + if is_favorite_game: + self.logger.debug(f"Final count: balls={balls}, strikes={strikes}") + + # Get base runners + bases_occupied = [ + situation.get("onFirst", False), + situation.get("onSecond", False), + situation.get("onThird", False), + ] + + if is_favorite_game: + self.logger.debug(f"Bases occupied: {bases_occupied}") + else: + # Default values for non-live games + inning = 1 + inning_half = "top" + balls = 0 + strikes = 0 + outs = 0 + bases_occupied = [False, False, False] + + details.update( + { + "status": game_status, + "status_state": status_state, + "inning": inning, + "inning_half": inning_half, + "balls": balls, + "strikes": strikes, + "outs": outs, + "bases_occupied": bases_occupied, + "start_time": game_event["date"], + "series_summary": series_summary, + } + ) + + # Basic validation (can be expanded) + if not details["home_abbr"] or not details["away_abbr"]: + self.logger.warning( + f"Missing team abbreviation in event: {details['id']}" + ) + return None + + self.logger.debug( + f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}" + ) + + return details + except Exception as e: + # Log the problematic event structure if possible + self.logger.error( + f"Error extracting game details: {e} from event: {game_event.get('id')}", + exc_info=True, + ) + return None + + def display_series_summary(self, game: dict, draw_overlay: ImageDraw.ImageDraw): + if not self.show_series_summary: + return + + series_summary = game.get("series_summary", "") + font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + bbox = draw_overlay.textbbox((0, 0), series_summary, font=self.fonts['time']) + height = bbox[3] - bbox[1] + shots_y = (self.display_height - height) // 2 + shots_width = draw_overlay.textlength(series_summary, font=self.fonts['time']) + shots_x = (self.display_width - shots_width) // 2 + self._draw_text_with_outline( + draw_overlay, series_summary, (shots_x, shots_y), self.fonts['time'] + ) + +class BaseballRecent(Baseball, SportsRecent): + """Base class for recent baseball games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + + + def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): + self.display_series_summary(game, draw_overlay) + + +class BaseballLive(Baseball, SportsLive): + """Base class for live baseball games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + + def _test_mode_update(self): + if self.current_game and self.current_game["is_live"]: + if self.current_game["inning_half"] == "top": + self.current_game["inning_half"] = "bottom" + else: + self.current_game["inning_half"] = "top" + self.current_game["inning"] += 1 + self.current_game["balls"] = (self.current_game["balls"] + 1) % 4 + self.current_game["strikes"] = (self.current_game["strikes"] + 1) % 3 + self.current_game["outs"] = (self.current_game["outs"] + 1) % 3 + self.current_game["bases_occupied"] = [ + not b for b in self.current_game["bases_occupied"] + ] + if self.current_game["inning"] % 2 == 0: + self.current_game["home_score"] = str( + int(self.current_game["home_score"]) + 1 + ) + else: + self.current_game["away_score"] = str( + int(self.current_game["away_score"]) + 1 + ) + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the detailed scorebug layout for a live baseball game.""" + try: + 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 text elements on overlay first + + home_logo = self._load_and_resize_logo( + game["home_id"], + game["home_abbr"], + game["home_logo_path"], + game.get("home_logo_url"), + ) + away_logo = self._load_and_resize_logo( + game["away_id"], + game["away_abbr"], + game["away_logo_path"], + game.get("away_logo_url"), + ) + + if not home_logo or not away_logo: + self.logger.error( + f"Failed to load logos for live game: {game.get('id')}" + ) + # Draw placeholder text if logos fail + 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 more inward than NHL perhaps) + 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 --- + + # Define default text color + text_color = (255, 255, 255) + + # Draw Inning (Top Center) + inning_half = game["inning_half"] + inning_num = game["inning"] + if game["is_final"]: + inning_text = "FINAL" + else: + inning_half_indicator = ( + "▲" if game["inning_half"].lower() == "top" else "▼" + ) + inning_num = game["inning"] + inning_text = f"{inning_half_indicator}{inning_num}" + + inning_bbox = draw_overlay.textbbox( + (0, 0), inning_text, font=self.display_manager.font + ) + inning_width = inning_bbox[2] - inning_bbox[0] + inning_x = (self.display_width - inning_width) // 2 + inning_y = 1 # Position near top center + self._draw_text_with_outline( + draw_overlay, + inning_text, + (inning_x, inning_y), + self.display_manager.font, + ) + + # --- REVISED BASES AND OUTS DRAWING --- + bases_occupied = game["bases_occupied"] # [1st, 2nd, 3rd] + outs = game.get("outs", 0) + inning_half = game["inning_half"] + + # Define geometry + base_diamond_size = 7 + out_circle_diameter = 3 + out_vertical_spacing = 2 # Space between out circles + spacing_between_bases_outs = ( + 3 # Horizontal space between base cluster and out column + ) + base_vert_spacing = 1 # Internal vertical space in base cluster + base_horiz_spacing = 1 # Internal horizontal space in base cluster + + # Calculate cluster dimensions + 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 + + # Calculate overall start positions + overall_start_y = ( + inning_bbox[3] + 0 + ) # Start immediately below inning text + + # 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": # Away batting, outs on left + outs_column_x = ( + bases_origin_x - spacing_between_bases_outs - out_cluster_width + ) + else: # Home batting, outs on right + outs_column_x = ( + bases_origin_x + base_cluster_width + spacing_between_bases_outs + ) + + # Calculate vertical alignment offset for outs column (center align with bases cluster) + 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) # Outline color + h_d = base_diamond_size // 2 + + # 2nd Base (Top center relative to bases_origin_x) + 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 # Bottom Y of 2nd base diamond + + # 3rd Base (Bottom left relative to bases_origin_x) + 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 relative to bases_origin_x) + 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 (BDF Font) --- + balls = game.get("balls", 0) + strikes = game.get("strikes", 0) + + # Add debug logging for count with cooldown + current_time = time.time() + if ( + game["home_abbr"] in self.favorite_teams + or game["away_abbr"] in self.favorite_teams + ) and current_time - self.last_count_log_time >= self.count_log_interval: + self.logger.debug(f"Displaying count: {balls}-{strikes}") + self.logger.debug( + f"Raw count data: balls={game.get('balls')}, strikes={game.get('strikes')}" + ) + self.last_count_log_time = current_time + + count_text = f"{balls}-{strikes}" + bdf_font = self.display_manager.calendar_font + bdf_font.set_char_size(height=7 * 64) # Set 7px height + count_text_width = self.display_manager.get_text_width(count_text, bdf_font) + + # Position below the base/out cluster + cluster_bottom_y = ( + overall_start_y + base_cluster_height + ) # Find the bottom of the taller part (bases) + count_y = cluster_bottom_y + 2 # Start 2 pixels below cluster + + # Center horizontally within the BASE cluster width + count_x = bases_origin_x + (base_cluster_width - count_text_width) // 2 + + # Ensure draw object is set and draw text + self.display_manager.draw = draw_overlay + + # Draw Balls-Strikes Count with outline using BDF font + outline_color_for_bdf = (0, 0, 0) + + # Draw outline + 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, + ) + + # Draw main text + self.display_manager._draw_bdf_text( + count_text, count_x, count_y, color=text_color, font=bdf_font + ) + + # Draw Team:Score at the bottom (matching main branch format) + score_font = self.display_manager.font # Use PressStart2P + outline_color = (0, 0, 0) + score_text_color = ( + 255, + 255, + 255, + ) + + # Helper function for outlined text + def draw_bottom_outlined_text(x, y, text): + self._draw_text_with_outline( + draw_overlay, + text, + (x, y), + score_font, + fill=score_text_color, + outline_color=outline_color, + ) + + away_abbr = game["away_abbr"] + home_abbr = game["home_abbr"] + away_score_str = str(game["away_score"]) + home_score_str = str(game["home_score"]) + + away_text = f"{away_abbr}:{away_score_str}" + home_text = f"{home_abbr}:{home_score_str}" + + # Calculate Y position (bottom edge) + try: + font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1] + except AttributeError: + font_height = 8 # Fallback for default font + score_y = ( + self.display_height - font_height - 2 + ) # 2 pixels padding from bottom + + # Away Team:Score (Bottom Left) + away_score_x = 2 # 2 pixels padding from left + draw_bottom_outlined_text(away_score_x, score_y, away_text) + + # Home Team:Score (Bottom Right) + home_text_bbox = draw_overlay.textbbox((0, 0), home_text, font=score_font) + home_text_width = home_text_bbox[2] - home_text_bbox[0] + home_score_x = ( + self.display_width - home_text_width - 2 + ) # 2 pixels padding from right + draw_bottom_outlined_text(home_score_x, score_y, home_text) + + # Draw gambling odds if available + if "odds" in game and game["odds"]: + self._draw_dynamic_odds( + draw_overlay, game["odds"], self.display_width, self.display_height + ) + + # Composite the text overlay onto the main image + main_img = Image.alpha_composite(main_img, overlay) + main_img = main_img.convert("RGB") # Convert for display + + # Display the final image + self.display_manager.image.paste(main_img, (0, 0)) + self.display_manager.update_display() # Update display here for live + + except Exception as e: + self.logger.error( + f"Error displaying live Baseball game: {e}", exc_info=True + ) diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 936ce24..e057c29 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Baseball Scoreboard Plugin Configuration", - "description": "Configuration schema for the Baseball Scoreboard plugin", + "description": "Configuration schema for the Baseball Scoreboard plugin - displays live, recent, and upcoming MLB, MiLB, and NCAA Baseball games", "type": "object", "properties": { "enabled": { @@ -10,43 +10,11 @@ "description": "Enable or disable the baseball scoreboard plugin" }, "display_duration": { - "type": "number", - "default": 15, - "minimum": 5, - "maximum": 60, - "description": "Duration in seconds to display each game" - }, - "show_records": { - "type": "boolean", - "default": false, - "description": "Show team records (wins-losses)" - }, - "show_ranking": { - "type": "boolean", - "default": false, - "description": "Show team rankings (when available)" - }, - "live_priority": { - "type": "boolean", - "default": false, - "description": "Prioritize live games over scheduled games" - }, - "live_game_duration": { "type": "number", "default": 30, - "minimum": 10, - "maximum": 120, - "description": "Duration in seconds to display live games" - }, - "show_odds": { - "type": "boolean", - "default": true, - "description": "Show betting odds for games" - }, - "test_mode": { - "type": "boolean", - "default": false, - "description": "Enable test mode for development" + "minimum": 5, + "maximum": 300, + "description": "Duration in seconds for the display controller to show this plugin mode before rotating to next plugin" }, "update_interval": { "type": "integer", @@ -55,379 +23,825 @@ "maximum": 86400, "description": "How often to fetch new data in seconds" }, - "live_update_interval": { - "type": "integer", - "default": 30, - "minimum": 10, - "maximum": 300, - "description": "Update interval for live games (seconds)" - }, - "live_odds_update_interval": { - "type": "integer", - "default": 3600, - "minimum": 60, - "maximum": 86400, - "description": "Update interval for live odds (seconds)" - }, - "odds_update_interval": { - "type": "integer", - "default": 3600, - "minimum": 60, - "maximum": 86400, - "description": "Update interval for odds data (seconds)" - }, - "recent_update_interval": { - "type": "integer", - "default": 3600, - "minimum": 60, - "maximum": 86400, - "description": "Update interval for recent games (seconds)" - }, - "upcoming_update_interval": { - "type": "integer", - "default": 3600, - "minimum": 60, - "maximum": 86400, - "description": "Update interval for upcoming games (seconds)" - }, - "show_favorite_teams_only": { - "type": "boolean", - "default": true, - "description": "Only show games from favorite teams" - }, - "logo_dir": { - "type": "string", - "default": "assets/sports/mlb_logos", - "description": "Directory path for team logos" - }, - "show_all_live": { - "type": "boolean", - "default": false, - "description": "Show all live games, not just favorites" - }, - "show_series_summary": { - "type": "boolean", - "default": false, - "description": "Show series summary information" - }, - "mlb_enabled": { - "type": "boolean", - "default": true, - "description": "Enable MLB games" - }, - "mlb_favorite_teams": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[A-Z]{2,3}$", - "description": "MLB team abbreviation (e.g., 'NYY', 'BOS')" - }, - "uniqueItems": true, - "maxItems": 30, - "description": "List of favorite MLB teams to prioritize" - }, - "mlb_display_modes_live": { - "type": "boolean", - "default": true, - "description": "Show live MLB games" - }, - "mlb_display_modes_recent": { - "type": "boolean", - "default": true, - "description": "Show recently completed MLB games" - }, - "mlb_display_modes_upcoming": { - "type": "boolean", - "default": true, - "description": "Show upcoming MLB games" - }, - "mlb_recent_games_to_show": { - "type": "integer", - "default": 5, - "minimum": 1, - "maximum": 20, - "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." - }, - "mlb_upcoming_games_to_show": { - "type": "integer", - "default": 1, - "minimum": 1, - "maximum": 20, - "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." - }, - "mlb_background_service_enabled": { - "type": "boolean", - "default": true, - "description": "Enable background service for data fetching" - }, - "mlb_background_service_max_workers": { - "type": "integer", - "default": 3, - "minimum": 1, - "maximum": 10, - "description": "Maximum number of worker threads" - }, - "mlb_background_service_request_timeout": { - "type": "integer", - "default": 30, - "minimum": 5, - "maximum": 120, - "description": "Request timeout in seconds" - }, - "mlb_background_service_max_retries": { - "type": "integer", - "default": 3, - "minimum": 1, - "maximum": 10, - "description": "Maximum number of retries for failed requests" - }, - "mlb_background_service_priority": { - "type": "integer", - "default": 2, - "minimum": 1, - "maximum": 5, - "description": "Background service priority" - }, - "milb_enabled": { - "type": "boolean", - "default": false, - "description": "Enable MiLB games" - }, - "milb_live_priority": { - "type": "boolean", - "default": false, - "description": "Prioritize live games over scheduled games" - }, - "milb_live_game_duration": { + "game_display_duration": { "type": "number", - "default": 30, - "minimum": 10, - "maximum": 120, - "description": "Duration in seconds to display live games" - }, - "milb_test_mode": { - "type": "boolean", - "default": false, - "description": "Enable test mode for development" - }, - "milb_update_interval_seconds": { - "type": "integer", - "default": 3600, - "minimum": 30, - "maximum": 86400, - "description": "How often to fetch new data (seconds)" - }, - "milb_live_update_interval": { - "type": "integer", - "default": 30, - "minimum": 10, - "maximum": 300, - "description": "Update interval for live games (seconds)" - }, - "milb_recent_update_interval": { - "type": "integer", - "default": 3600, - "minimum": 60, - "maximum": 86400, - "description": "Update interval for recent games (seconds)" - }, - "milb_upcoming_update_interval": { - "type": "integer", - "default": 3600, - "minimum": 60, - "maximum": 86400, - "description": "Update interval for upcoming games (seconds)" - }, - "milb_recent_games_to_show": { - "type": "integer", - "default": 1, - "minimum": 1, - "maximum": 20, - "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." - }, - "milb_upcoming_games_to_show": { - "type": "integer", - "default": 1, - "minimum": 1, - "maximum": 20, - "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." - }, - "milb_favorite_teams": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[A-Z]{2,4}$", - "description": "MiLB team abbreviation (e.g., 'DUR', 'SWB')" - }, - "uniqueItems": true, - "maxItems": 120, - "description": "List of favorite MiLB teams to prioritize" - }, - "milb_display_modes_live": { - "type": "boolean", - "default": true, - "description": "Show live MiLB games" - }, - "milb_display_modes_recent": { - "type": "boolean", - "default": true, - "description": "Show recently completed MiLB games" - }, - "milb_display_modes_upcoming": { - "type": "boolean", - "default": true, - "description": "Show upcoming MiLB games" - }, - "milb_logo_dir": { - "type": "string", - "default": "assets/sports/milb_logos", - "description": "Directory path for team logos" - }, - "milb_show_records": { - "type": "boolean", - "default": true, - "description": "Show team records (wins-losses)" - }, - "milb_upcoming_fetch_days": { - "type": "integer", - "default": 7, - "minimum": 1, - "maximum": 30, - "description": "Number of days to fetch upcoming games" - }, - "milb_background_service_enabled": { - "type": "boolean", - "default": true, - "description": "Enable background service for data fetching" - }, - "milb_background_service_max_workers": { - "type": "integer", - "default": 3, - "minimum": 1, - "maximum": 10, - "description": "Maximum number of worker threads" - }, - "milb_background_service_request_timeout": { - "type": "integer", - "default": 30, - "minimum": 5, - "maximum": 120, - "description": "Request timeout in seconds" - }, - "milb_background_service_max_retries": { - "type": "integer", - "default": 3, - "minimum": 1, - "maximum": 10, - "description": "Maximum number of retries for failed requests" - }, - "milb_background_service_priority": { - "type": "integer", - "default": 2, - "minimum": 1, - "maximum": 5, - "description": "Background service priority" - }, - "ncaa_baseball_enabled": { - "type": "boolean", - "default": false, - "description": "Enable NCAA Baseball games" - }, - "ncaa_baseball_live_priority": { - "type": "boolean", - "default": true, - "description": "Prioritize live games over scheduled games" - }, - "ncaa_baseball_live_game_duration": { - "type": "number", - "default": 30, - "minimum": 10, - "maximum": 120, - "description": "Duration in seconds to display live games" - }, - "ncaa_baseball_show_odds": { - "type": "boolean", - "default": true, - "description": "Show betting odds for games" - }, - "ncaa_baseball_test_mode": { - "type": "boolean", - "default": false, - "description": "Enable test mode for development" - }, - "ncaa_baseball_update_interval_seconds": { - "type": "integer", - "default": 3600, - "minimum": 30, - "maximum": 86400, - "description": "How often to fetch new data (seconds)" - }, - "ncaa_baseball_live_update_interval": { - "type": "integer", - "default": 30, - "minimum": 10, - "maximum": 300, - "description": "Update interval for live games (seconds)" - }, - "ncaa_baseball_recent_games_to_show": { - "type": "integer", - "default": 1, - "minimum": 1, - "maximum": 20, - "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." - }, - "ncaa_baseball_upcoming_games_to_show": { - "type": "integer", - "default": 1, - "minimum": 1, - "maximum": 20, - "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." - }, - "ncaa_baseball_show_favorite_teams_only": { - "type": "boolean", - "default": true, - "description": "Only show games from favorite teams" - }, - "ncaa_baseball_favorite_teams": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[A-Z]{2,4}$", - "description": "NCAA Baseball team abbreviation (e.g., 'LSU', 'FLA')" - }, - "uniqueItems": true, - "maxItems": 300, - "description": "List of favorite NCAA Baseball teams to prioritize" - }, - "ncaa_baseball_display_modes_live": { - "type": "boolean", - "default": true, - "description": "Show live NCAA Baseball games" - }, - "ncaa_baseball_display_modes_recent": { - "type": "boolean", - "default": true, - "description": "Show recently completed NCAA Baseball games" - }, - "ncaa_baseball_display_modes_upcoming": { - "type": "boolean", - "default": true, - "description": "Show upcoming NCAA Baseball games" + "default": 15, + "minimum": 3, + "maximum": 60, + "description": "Duration in seconds to show each individual game before rotating to the next game within the same mode" }, - "ncaa_baseball_logo_dir": { - "type": "string", - "default": "assets/sports/ncaa_logos", - "description": "Directory path for team logos" + "mlb": { + "type": "object", + "title": "MLB Settings", + "description": "Configuration for MLB games", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable MLB games" + }, + "favorite_teams": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "uniqueItems": true, + "maxItems": 30, + "description": "List of favorite MLB team abbreviations (e.g., NYY, BOS, LAD). Use 2-3 letter codes." + }, + "display_modes": { + "type": "object", + "title": "Display Modes", + "description": "Control which game types to show", + "properties": { + "show_live": { + "type": "boolean", + "default": true, + "description": "Show live MLB games" + }, + "show_recent": { + "type": "boolean", + "default": true, + "description": "Show recently completed MLB games" + }, + "show_upcoming": { + "type": "boolean", + "default": true, + "description": "Show upcoming MLB games" + } + } + }, + "live_priority": { + "type": "boolean", + "default": false, + "description": "Give live games priority over other modes. When enabled, live games will interrupt the normal mode rotation and be displayed immediately when available." + }, + "live_game_duration": { + "type": "integer", + "default": 30, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to display each live game before rotating to the next live game" + }, + "recent_game_duration": { + "type": "number", + "default": 15, + "description": "Duration in seconds to show each recent game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." + }, + "upcoming_game_duration": { + "type": "number", + "default": 15, + "description": "Duration in seconds to show each upcoming game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." + }, + "live_update_interval": { + "type": "integer", + "default": 30, + "minimum": 5, + "maximum": 300, + "description": "How often to update live game data (seconds)" + }, + "update_interval_seconds": { + "type": "integer", + "default": 3600, + "minimum": 30, + "maximum": 86400, + "description": "How often to fetch new data for this league (seconds)" + }, + "game_limits": { + "type": "object", + "title": "Game Limits", + "description": "Control how many games to show", + "properties": { + "recent_games_to_show": { + "type": "integer", + "default": 5, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + }, + "upcoming_games_to_show": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + } + } + }, + "display_options": { + "type": "object", + "title": "Display Options", + "description": "Additional information to show", + "properties": { + "show_records": { + "type": "boolean", + "default": false, + "description": "Show team records (wins-losses)" + }, + "show_ranking": { + "type": "boolean", + "default": false, + "description": "Show team rankings (when available)" + }, + "show_odds": { + "type": "boolean", + "default": true, + "description": "Show betting odds" + }, + "show_series_summary": { + "type": "boolean", + "default": false, + "description": "Show series summary information" + } + } + }, + "filtering": { + "type": "object", + "title": "Filtering Options", + "description": "Control which teams are shown", + "properties": { + "show_favorite_teams_only": { + "type": "boolean", + "default": true, + "description": "Only show games from favorite teams" + }, + "show_all_live": { + "type": "boolean", + "default": false, + "description": "Show all live games, not just favorites" + } + } + }, + "mode_durations": { + "type": "object", + "title": "Mode Duration Overrides", + "description": "Override how long each mode displays before rotating", + "properties": { + "recent_mode_duration": { + "type": ["number", "null"], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for recent games mode (seconds). Null uses default." + }, + "upcoming_mode_duration": { + "type": ["number", "null"], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for upcoming games mode (seconds). Null uses default." + }, + "live_mode_duration": { + "type": ["number", "null"], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for live games mode (seconds). Null uses default." + } + } + }, + "dynamic_duration": { + "type": "object", + "title": "MLB Dynamic Duration Settings", + "description": "Configure dynamic duration settings for MLB games.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MLB games" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "default": 30, + "description": "Minimum total duration in seconds for this mode, even if few games are available. Ensures the mode stays visible long enough." + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum total duration in seconds for this mode, even if many games are available." + }, + "modes": { + "type": "object", + "title": "Per-Mode Settings for MLB", + "description": "Configure dynamic duration for specific MLB modes", + "properties": { + "live": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MLB live games" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for MLB live mode" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for MLB live games" + } + } + }, + "recent": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MLB recent games" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for MLB recent mode" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for MLB recent games" + } + } + }, + "upcoming": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MLB upcoming games" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for MLB upcoming mode" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for MLB upcoming games" + } + } + } + } + } + } + } + } }, - "ncaa_baseball_show_records": { - "type": "boolean", - "default": true, - "description": "Show team records (wins-losses)" + "milb": { + "type": "object", + "title": "MiLB Settings", + "description": "Configuration for MiLB (Minor League Baseball) games", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable MiLB games" + }, + "favorite_teams": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "uniqueItems": true, + "maxItems": 120, + "description": "List of favorite MiLB team abbreviations (e.g., DUR, SWB). Use 2-4 letter codes." + }, + "display_modes": { + "type": "object", + "title": "Display Modes", + "description": "Control which game types to show", + "properties": { + "show_live": { + "type": "boolean", + "default": true, + "description": "Show live MiLB games" + }, + "show_recent": { + "type": "boolean", + "default": true, + "description": "Show recently completed MiLB games" + }, + "show_upcoming": { + "type": "boolean", + "default": true, + "description": "Show upcoming MiLB games" + } + } + }, + "live_priority": { + "type": "boolean", + "default": false, + "description": "Give live games priority over other modes. When enabled, live games will interrupt the normal mode rotation and be displayed immediately when available." + }, + "live_game_duration": { + "type": "integer", + "default": 30, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to display each live game before rotating to the next live game" + }, + "recent_game_duration": { + "type": "number", + "default": 15, + "description": "Duration in seconds to show each recent game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." + }, + "upcoming_game_duration": { + "type": "number", + "default": 15, + "description": "Duration in seconds to show each upcoming game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." + }, + "live_update_interval": { + "type": "integer", + "default": 30, + "minimum": 5, + "maximum": 300, + "description": "How often to update live game data (seconds)" + }, + "update_interval_seconds": { + "type": "integer", + "default": 3600, + "minimum": 30, + "maximum": 86400, + "description": "How often to fetch new data for this league (seconds)" + }, + "game_limits": { + "type": "object", + "title": "Game Limits", + "description": "Control how many games to show", + "properties": { + "recent_games_to_show": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + }, + "upcoming_games_to_show": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + } + } + }, + "display_options": { + "type": "object", + "title": "Display Options", + "description": "Additional information to show", + "properties": { + "show_records": { + "type": "boolean", + "default": false, + "description": "Show team records (wins-losses)" + }, + "show_ranking": { + "type": "boolean", + "default": false, + "description": "Show team rankings (when available)" + }, + "show_odds": { + "type": "boolean", + "default": true, + "description": "Show betting odds" + }, + "show_series_summary": { + "type": "boolean", + "default": false, + "description": "Show series summary information" + } + } + }, + "filtering": { + "type": "object", + "title": "Filtering Options", + "description": "Control which teams are shown", + "properties": { + "show_favorite_teams_only": { + "type": "boolean", + "default": true, + "description": "Only show games from favorite teams" + }, + "show_all_live": { + "type": "boolean", + "default": false, + "description": "Show all live games, not just favorites" + } + } + }, + "mode_durations": { + "type": "object", + "title": "Mode Duration Overrides", + "description": "Override how long each mode displays before rotating", + "properties": { + "recent_mode_duration": { + "type": ["number", "null"], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for recent games mode (seconds). Null uses default." + }, + "upcoming_mode_duration": { + "type": ["number", "null"], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for upcoming games mode (seconds). Null uses default." + }, + "live_mode_duration": { + "type": ["number", "null"], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for live games mode (seconds). Null uses default." + } + } + }, + "dynamic_duration": { + "type": "object", + "title": "MiLB Dynamic Duration Settings", + "description": "Configure dynamic duration settings for MiLB games.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MiLB games" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "default": 30, + "description": "Minimum total duration in seconds for this mode, even if few games are available. Ensures the mode stays visible long enough." + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum total duration in seconds for this mode, even if many games are available." + }, + "modes": { + "type": "object", + "title": "Per-Mode Settings for MiLB", + "description": "Configure dynamic duration for specific MiLB modes", + "properties": { + "live": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MiLB live games" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for MiLB live mode" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for MiLB live games" + } + } + }, + "recent": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MiLB recent games" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for MiLB recent mode" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for MiLB recent games" + } + } + }, + "upcoming": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for MiLB upcoming games" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for MiLB upcoming mode" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for MiLB upcoming games" + } + } + } + } + } + } + } + } }, - "ncaa_baseball_show_all_live": { - "type": "boolean", - "default": false, - "description": "Show all live games, not just favorites" + "ncaa_baseball": { + "type": "object", + "title": "NCAA Baseball Settings", + "description": "Configuration for NCAA Baseball games", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable NCAA Baseball games" + }, + "favorite_teams": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "uniqueItems": true, + "maxItems": 300, + "description": "List of favorite NCAA Baseball team abbreviations (e.g., LSU, FLA). Use 2-4 letter codes." + }, + "display_modes": { + "type": "object", + "title": "Display Modes", + "description": "Control which game types to show", + "properties": { + "show_live": { + "type": "boolean", + "default": true, + "description": "Show live NCAA Baseball games" + }, + "show_recent": { + "type": "boolean", + "default": true, + "description": "Show recently completed NCAA Baseball games" + }, + "show_upcoming": { + "type": "boolean", + "default": true, + "description": "Show upcoming NCAA Baseball games" + } + } + }, + "live_priority": { + "type": "boolean", + "default": true, + "description": "Give live games priority over other modes. When enabled, live games will interrupt the normal mode rotation and be displayed immediately when available." + }, + "live_game_duration": { + "type": "integer", + "default": 30, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to display each live game before rotating to the next live game" + }, + "recent_game_duration": { + "type": "number", + "default": 15, + "description": "Duration in seconds to show each recent game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." + }, + "upcoming_game_duration": { + "type": "number", + "default": 15, + "description": "Duration in seconds to show each upcoming game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." + }, + "live_update_interval": { + "type": "integer", + "default": 30, + "minimum": 5, + "maximum": 300, + "description": "How often to update live game data (seconds)" + }, + "update_interval_seconds": { + "type": "integer", + "default": 3600, + "minimum": 30, + "maximum": 86400, + "description": "How often to fetch new data for this league (seconds)" + }, + "game_limits": { + "type": "object", + "title": "Game Limits", + "description": "Control how many games to show", + "properties": { + "recent_games_to_show": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + }, + "upcoming_games_to_show": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + } + } + }, + "display_options": { + "type": "object", + "title": "Display Options", + "description": "Additional information to show", + "properties": { + "show_records": { + "type": "boolean", + "default": false, + "description": "Show team records (wins-losses)" + }, + "show_ranking": { + "type": "boolean", + "default": false, + "description": "Show team rankings (rankings can be important in college baseball)" + }, + "show_odds": { + "type": "boolean", + "default": true, + "description": "Show betting odds" + }, + "show_series_summary": { + "type": "boolean", + "default": false, + "description": "Show series summary information" + } + } + }, + "filtering": { + "type": "object", + "title": "Filtering Options", + "description": "Control which teams are shown", + "properties": { + "show_favorite_teams_only": { + "type": "boolean", + "default": true, + "description": "Only show games from favorite teams" + }, + "show_all_live": { + "type": "boolean", + "default": false, + "description": "Show all live games, not just favorites" + } + } + }, + "mode_durations": { + "type": "object", + "title": "Mode Duration Overrides", + "description": "Override how long each mode displays before rotating", + "properties": { + "recent_mode_duration": { + "type": ["number", "null"], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for recent games mode (seconds). Null uses default." + }, + "upcoming_mode_duration": { + "type": ["number", "null"], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for upcoming games mode (seconds). Null uses default." + }, + "live_mode_duration": { + "type": ["number", "null"], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Override display duration for live games mode (seconds). Null uses default." + } + } + }, + "dynamic_duration": { + "type": "object", + "title": "NCAA Baseball Dynamic Duration Settings", + "description": "Configure dynamic duration settings for NCAA Baseball games.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Baseball games" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "default": 30, + "description": "Minimum total duration in seconds for this mode, even if few games are available. Ensures the mode stays visible long enough." + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum total duration in seconds for this mode, even if many games are available." + }, + "modes": { + "type": "object", + "title": "Per-Mode Settings for NCAA Baseball", + "description": "Configure dynamic duration for specific NCAA Baseball modes", + "properties": { + "live": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Baseball live games" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for NCAA Baseball live mode" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for NCAA Baseball live games" + } + } + }, + "recent": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Baseball recent games" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for NCAA Baseball recent mode" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for NCAA Baseball recent games" + } + } + }, + "upcoming": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Baseball upcoming games" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for NCAA Baseball upcoming mode" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for NCAA Baseball upcoming games" + } + } + } + } + } + } + } + } }, "customization": { "type": "object", diff --git a/plugins/baseball-scoreboard/data_sources.py b/plugins/baseball-scoreboard/data_sources.py new file mode 100644 index 0000000..76c1644 --- /dev/null +++ b/plugins/baseball-scoreboard/data_sources.py @@ -0,0 +1,307 @@ +""" +Pluggable Data Source Architecture + +This module provides abstract data sources that can be plugged into the sports system +to support different APIs and data providers. +""" + +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional, List +import requests +import logging +from datetime import datetime, timedelta +import time + +class DataSource(ABC): + """Abstract base class for data sources.""" + + def __init__(self, logger: logging.Logger): + self.logger = logger + self.session = requests.Session() + + # Configure retry strategy + from requests.adapters import HTTPAdapter + from urllib3.util.retry import Retry + + retry_strategy = Retry( + total=5, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) + + @abstractmethod + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: + """Fetch live games for a sport/league.""" + pass + + @abstractmethod + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: + """Fetch schedule for a sport/league within date range.""" + pass + + @abstractmethod + def fetch_standings(self, sport: str, league: str) -> Dict: + """Fetch standings for a sport/league.""" + pass + + def get_headers(self) -> Dict[str, str]: + """Get headers for API requests.""" + return { + 'User-Agent': 'LEDMatrix/1.0', + 'Accept': 'application/json' + } + + +class ESPNDataSource(DataSource): + """ESPN API data source.""" + + def __init__(self, logger: logging.Logger): + super().__init__(logger) + self.base_url = "https://site.api.espn.com/apis/site/v2/sports" + + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: + """Fetch live games from ESPN API.""" + try: + now = datetime.now() + formatted_date = now.strftime("%Y%m%d") + url = f"{self.base_url}/{sport}/{league}/scoreboard" + response = self.session.get(url, params={"dates": formatted_date, "limit": 1000}, headers=self.get_headers(), timeout=15) + response.raise_for_status() + + data = response.json() + events = data.get('events', []) + + # Filter for live games + live_events = [event for event in events + if event.get('competitions', [{}])[0].get('status', {}).get('type', {}).get('state') == 'in'] + + self.logger.debug(f"Fetched {len(live_events)} live games for {sport}/{league}") + return live_events + + except Exception as e: + self.logger.error(f"Error fetching live games from ESPN: {e}") + return [] + + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: + """Fetch schedule from ESPN API.""" + try: + start_date, end_date = date_range + url = f"{self.base_url}/{sport}/{league}/scoreboard" + + params = { + 'dates': f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}", + "limit": 1000 + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + events = data.get('events', []) + + self.logger.debug(f"Fetched {len(events)} scheduled games for {sport}/{league}") + return events + + except Exception as e: + self.logger.error(f"Error fetching schedule from ESPN: {e}") + return [] + + def fetch_standings(self, sport: str, league: str) -> Dict: + """Fetch standings from ESPN API.""" + # Try standings endpoint first (for professional leagues like NFL) + try: + url = f"{self.base_url}/{sport}/{league}/standings" + response = self.session.get(url, headers=self.get_headers(), timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug(f"Fetched standings for {sport}/{league}") + return data + except Exception as e: + # If standings doesn't exist, try rankings (for college sports) + if hasattr(e, 'response') and hasattr(e.response, 'status_code') and e.response.status_code == 404: + try: + url = f"{self.base_url}/{sport}/{league}/rankings" + response = self.session.get(url, headers=self.get_headers(), timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug(f"Fetched rankings for {sport}/{league} (fallback)") + return data + except Exception: + # Both endpoints failed - standings/rankings may not be available for this sport/league + self.logger.debug(f"Standings/rankings not available for {sport}/{league} from ESPN API") + return {} + else: + # Non-404 error - log at error level since this is unexpected + self.logger.error(f"Error fetching standings from ESPN for {sport}/{league}: {e}") + return {} + + +class MLBAPIDataSource(DataSource): + """MLB API data source.""" + + def __init__(self, logger: logging.Logger): + super().__init__(logger) + self.base_url = "https://statsapi.mlb.com/api/v1" + + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: + """Fetch live games from MLB API.""" + try: + url = f"{self.base_url}/schedule" + params = { + 'sportId': 1, # MLB + 'date': datetime.now().strftime('%Y-%m-%d'), + 'hydrate': 'game,team,venue,weather' + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + games = data.get('dates', [{}])[0].get('games', []) + + # Filter for live games + live_games = [game for game in games + if game.get('status', {}).get('abstractGameState') == 'Live'] + + self.logger.debug(f"Fetched {len(live_games)} live games from MLB API") + return live_games + + except Exception as e: + self.logger.error(f"Error fetching live games from MLB API: {e}") + return [] + + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: + """Fetch schedule from MLB API.""" + try: + start_date, end_date = date_range + url = f"{self.base_url}/schedule" + + params = { + 'sportId': 1, # MLB + 'startDate': start_date.strftime('%Y-%m-%d'), + 'endDate': end_date.strftime('%Y-%m-%d'), + 'hydrate': 'game,team,venue' + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + all_games = [] + for date_data in data.get('dates', []): + all_games.extend(date_data.get('games', [])) + + self.logger.debug(f"Fetched {len(all_games)} scheduled games from MLB API") + return all_games + + except Exception as e: + self.logger.error(f"Error fetching schedule from MLB API: {e}") + return [] + + def fetch_standings(self, sport: str, league: str) -> Dict: + """Fetch standings from MLB API.""" + try: + url = f"{self.base_url}/standings" + params = { + 'leagueId': 103, # American League + 'season': datetime.now().year, + 'standingsType': 'regularSeason' + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug(f"Fetched standings from MLB API") + return data + + except Exception as e: + self.logger.error(f"Error fetching standings from MLB API: {e}") + return {} + + +class SoccerAPIDataSource(DataSource): + """Soccer API data source (generic structure).""" + + def __init__(self, logger: logging.Logger, api_key: str = None): + super().__init__(logger) + self.api_key = api_key + self.base_url = "https://api.football-data.org/v4" # Example API + + def get_headers(self) -> Dict[str, str]: + """Get headers with API key for soccer API.""" + headers = super().get_headers() + if self.api_key: + headers['X-Auth-Token'] = self.api_key + return headers + + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: + """Fetch live games from soccer API.""" + try: + # This would need to be adapted based on the specific soccer API + url = f"{self.base_url}/matches" + params = { + 'status': 'LIVE', + 'competition': league + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + matches = data.get('matches', []) + + self.logger.debug(f"Fetched {len(matches)} live games from soccer API") + return matches + + except Exception as e: + self.logger.error(f"Error fetching live games from soccer API: {e}") + return [] + + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: + """Fetch schedule from soccer API.""" + try: + start_date, end_date = date_range + url = f"{self.base_url}/matches" + + params = { + 'competition': league, + 'dateFrom': start_date.strftime('%Y-%m-%d'), + 'dateTo': end_date.strftime('%Y-%m-%d') + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + matches = data.get('matches', []) + + self.logger.debug(f"Fetched {len(matches)} scheduled games from soccer API") + return matches + + except Exception as e: + self.logger.error(f"Error fetching schedule from soccer API: {e}") + return [] + + def fetch_standings(self, sport: str, league: str) -> Dict: + """Fetch standings from soccer API.""" + try: + url = f"{self.base_url}/competitions/{league}/standings" + response = self.session.get(url, headers=self.get_headers(), timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug(f"Fetched standings from soccer API") + return data + + except Exception as e: + self.logger.error(f"Error fetching standings from soccer API: {e}") + return {} + + +# Factory function removed - sport classes now instantiate data sources directly diff --git a/plugins/baseball-scoreboard/dynamic_team_resolver.py b/plugins/baseball-scoreboard/dynamic_team_resolver.py new file mode 100644 index 0000000..1b0b70f --- /dev/null +++ b/plugins/baseball-scoreboard/dynamic_team_resolver.py @@ -0,0 +1,179 @@ +""" +Simplified DynamicTeamResolver for plugin use +""" + +import logging +import time +import requests +from typing import Dict, List, Set, Optional, Any +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + +class DynamicTeamResolver: + """ + Simplified resolver for dynamic team names to actual team abbreviations. + + This class handles special team names that represent dynamic groups + like AP Top 25 rankings, which update automatically. + """ + + # Supported dynamic team patterns + DYNAMIC_PATTERNS = { + 'AP_TOP_25': {'sport': 'college-baseball', 'limit': 25}, + 'AP_TOP_10': {'sport': 'college-baseball', 'limit': 10}, + 'AP_TOP_5': {'sport': 'college-baseball', 'limit': 5}, + } + + # Cache duration in seconds (1 hour) + CACHE_DURATION = 3600 + + def __init__(self, cache_manager=None, request_timeout: int = 30): + """Initialize the dynamic team resolver. + + Args: + cache_manager: Optional cache manager instance for storing rankings cache + request_timeout: Timeout for API requests in seconds + """ + self.cache_manager = cache_manager + self.request_timeout = request_timeout + self.logger = logger + + def resolve_teams(self, team_list: List[str], sport: str = 'college-baseball') -> List[str]: + """ + Resolve a list of team names, expanding dynamic team names. + + Args: + team_list: List of team names (can include dynamic names like "AP_TOP_25") + sport: Sport type for context (default: 'college-baseball') + + Returns: + List of resolved team abbreviations + """ + if not team_list: + return [] + + resolved_teams = [] + + for team in team_list: + if team in self.DYNAMIC_PATTERNS: + # Resolve dynamic team + dynamic_teams = self._resolve_dynamic_team(team, sport) + resolved_teams.extend(dynamic_teams) + self.logger.info(f"Resolved {team} to {len(dynamic_teams)} teams: {dynamic_teams[:5]}{'...' if len(dynamic_teams) > 5 else ''}") + elif self._is_potential_dynamic_team(team): + # Unknown dynamic team, skip it + self.logger.warning(f"Unknown dynamic team '{team}' - skipping") + else: + # Regular team name, add as-is + resolved_teams.append(team) + + # Remove duplicates while preserving order + seen = set() + unique_teams = [] + for team in resolved_teams: + if team not in seen: + seen.add(team) + unique_teams.append(team) + + return unique_teams + + def _resolve_dynamic_team(self, dynamic_team: str, sport: str) -> List[str]: + """ + Resolve a dynamic team name to actual team abbreviations. + + Args: + dynamic_team: Dynamic team name (e.g., "AP_TOP_25") + sport: Sport type for context + + Returns: + List of team abbreviations + """ + try: + pattern_config = self.DYNAMIC_PATTERNS[dynamic_team] + pattern_sport = pattern_config['sport'] + limit = pattern_config['limit'] + + # Check cache first (using cache_manager if available) + cache_key = f"dynamic_teams_{pattern_sport}_{dynamic_team}" + if self.cache_manager: + cached_teams = self.cache_manager.get(cache_key) + if cached_teams: + self.logger.debug(f"Using cached {dynamic_team} teams from cache_manager") + return cached_teams[:limit] + + # Fetch fresh rankings + rankings = self._fetch_rankings(pattern_sport) + if rankings: + # Cache the results using cache_manager if available + if self.cache_manager: + self.cache_manager.set(cache_key, rankings, ttl=self.CACHE_DURATION) + + self.logger.info(f"Fetched {len(rankings)} teams for {dynamic_team}") + return rankings[:limit] + else: + self.logger.warning(f"Failed to fetch rankings for {dynamic_team}") + return [] + + except Exception as e: + self.logger.error(f"Error resolving dynamic team {dynamic_team}: {e}") + return [] + + def _fetch_rankings(self, sport: str) -> List[str]: + """ + Fetch current rankings from ESPN API. + + Args: + sport: Sport type (e.g., 'college-baseball') + + Returns: + List of team abbreviations in ranking order + """ + try: + # Map sport to ESPN API endpoint + sport_mapping = { + 'college-baseball': 'baseball/college-baseball/rankings', + 'ncaa_fb': 'football/college-football/rankings', + } + + endpoint = sport_mapping.get(sport) + if not endpoint: + self.logger.error(f"Unsupported sport for rankings: {sport}") + return [] + + url = f"https://site.api.espn.com/apis/site/v2/sports/{endpoint}" + + headers = { + 'User-Agent': 'LEDMatrix/1.0', + 'Accept': 'application/json' + } + + response = requests.get(url, headers=headers, timeout=self.request_timeout) + response.raise_for_status() + + data = response.json() + + # Extract team abbreviations from rankings + teams = [] + if 'rankings' in data and data['rankings']: + ranking = data['rankings'][0] # Use first ranking (usually AP) + if 'ranks' in ranking: + for rank_item in ranking['ranks']: + team_info = rank_item.get('team', {}) + abbr = team_info.get('abbreviation', '') + if abbr: + teams.append(abbr) + + self.logger.debug(f"Fetched {len(teams)} ranked teams for {sport}") + return teams + + except requests.exceptions.RequestException as e: + self.logger.error(f"API request failed for {sport} rankings: {e}") + return [] + except Exception as e: + self.logger.error(f"Error fetching rankings for {sport}: {e}") + return [] + + def _is_potential_dynamic_team(self, team: str) -> bool: + """Check if a team name looks like a dynamic team pattern.""" + return team.startswith('AP_') or team.startswith('TOP_') diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index b0bf182..9f46f63 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -78,13 +78,7 @@ def _load_fonts(self): return fonts 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 + """Get the logo path for a team based on league.""" if league == 'mlb': return Path("assets/sports/mlb_logos") / f"{team_abbrev}.png" elif league == 'milb': @@ -420,9 +414,11 @@ def _get_team_display_text(self, abbr: str, record: str, show_records: bool, sho 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)) + league = game.get('league', 'mlb') + league_config = self.config.get(league, {}) + display_options = league_config.get('display_options', {}) + show_records = display_options.get('show_records', self.config.get('show_records', False)) + show_ranking = display_options.get('show_ranking', self.config.get('show_ranking', False)) if not show_records and not show_ranking: return diff --git a/plugins/baseball-scoreboard/logo_downloader.py b/plugins/baseball-scoreboard/logo_downloader.py new file mode 100644 index 0000000..82fbc6e --- /dev/null +++ b/plugins/baseball-scoreboard/logo_downloader.py @@ -0,0 +1,176 @@ +""" +Simplified LogoDownloader for plugin use +""" + +import os +import logging +import requests +from typing import Dict, Any, List, Optional, Tuple +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +logger = logging.getLogger(__name__) + +class LogoDownloader: + """Simplified logo downloader for team logos from ESPN API.""" + + def __init__(self, request_timeout: int = 30, retry_attempts: int = 3): + """Initialize the logo downloader with HTTP session and retry logic.""" + self.request_timeout = request_timeout + self.retry_attempts = retry_attempts + + # Set up session with retry logic + self.session = requests.Session() + retry_strategy = Retry( + total=retry_attempts, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["GET", "HEAD", "OPTIONS"] + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("https://", adapter) + self.session.mount("http://", adapter) + + # Set up headers + self.headers = { + 'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)', + 'Accept': 'application/json', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive' + } + + @staticmethod + def normalize_abbreviation(abbr: str) -> str: + """Normalize team abbreviation for filename.""" + return abbr.upper() + + @staticmethod + def get_logo_filename_variations(abbr: str) -> List[str]: + """Get possible filename variations for a team abbreviation.""" + normalized = LogoDownloader.normalize_abbreviation(abbr) + variations = [f"{normalized}.png"] + + # Add common variations + if normalized == "TA&M": + variations.append("TAANDM.png") + elif normalized == "TAMU": + variations.append("TA&M.png") + + return variations + +def download_missing_logo(sport_key: str, team_id: str, team_abbr: str, logo_path: Path, logo_url: str = None) -> bool: + """ + Download missing logo for a team. + + Args: + sport_key: Sport key (e.g., 'nfl', 'ncaa_fb') + team_id: Team ID + team_abbr: Team abbreviation + logo_path: Path where logo should be saved + logo_url: Optional logo URL + + Returns: + True if logo was downloaded successfully, False otherwise + """ + try: + # Ensure directory exists and is writable + logo_dir = logo_path.parent + try: + logo_dir.mkdir(parents=True, exist_ok=True) + + # Check if we can write to the directory + test_file = logo_dir / '.write_test' + try: + test_file.touch() + test_file.unlink() + except PermissionError: + logger.error(f"Permission denied: Cannot write to directory {logo_dir}") + logger.error(f"Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh") + return False + except PermissionError as e: + logger.error(f"Permission denied: Cannot create directory {logo_dir}: {e}") + logger.error(f"Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh") + return False + except Exception as e: + logger.error(f"Failed to create logo directory {logo_dir}: {e}") + return False + + # If we have a logo URL, try to download it + if logo_url: + try: + response = requests.get(logo_url, timeout=30) + if response.status_code == 200: + # Verify it's an image + content_type = response.headers.get('content-type', '').lower() + if any(img_type in content_type for img_type in ['image/png', 'image/jpeg', 'image/jpg', 'image/gif']): + with open(logo_path, 'wb') as f: + f.write(response.content) + logger.info(f"Downloaded logo for {team_abbr} from {logo_url}") + return True + except PermissionError as e: + logger.error(f"Permission denied downloading logo for {team_abbr}: {e}") + logger.error(f"Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh") + return False + except Exception as e: + logger.error(f"Failed to download logo for {team_abbr}: {e}") + + # If no URL or download failed, create a placeholder + return create_placeholder_logo(team_abbr, logo_path) + + except PermissionError as e: + logger.error(f"Permission denied for {team_abbr}: {e}") + logger.error(f"Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh") + return False + except Exception as e: + logger.error(f"Failed to download logo for {team_abbr}: {e}") + # Try to create placeholder as fallback + try: + return create_placeholder_logo(team_abbr, logo_path) + except: + return False + +def create_placeholder_logo(team_abbr: str, logo_path: Path) -> bool: + """Create a simple placeholder logo.""" + try: + # Ensure directory exists + logo_path.parent.mkdir(parents=True, exist_ok=True) + + # Create a simple text-based logo + img = Image.new('RGBA', (64, 64), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Try to load a font + try: + font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) + except: + font = ImageFont.load_default() + + # Draw team abbreviation + text = team_abbr[:3] # Limit to 3 characters + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + x = (64 - text_width) // 2 + y = (64 - text_height) // 2 + + # Draw white text with black outline + 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=(0, 0, 0)) + draw.text((x, y), text, font=font, fill=(255, 255, 255)) + + # Save the placeholder + img.save(logo_path) + logger.info(f"Created placeholder logo for {team_abbr}") + return True + + except PermissionError as e: + logger.error(f"Permission denied creating placeholder logo for {team_abbr}: {e}") + logger.error(f"Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh") + return False + except Exception as e: + logger.error(f"Failed to create placeholder logo for {team_abbr}: {e}") + return False diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index b5f1c6b..45e77db 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -1,57 +1,56 @@ """ -Baseball Scoreboard Plugin for LEDMatrix +Baseball Scoreboard Plugin for LEDMatrix - Using Existing Managers -Displays live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball. -Shows real-time scores, game status, innings, and team logos. +This plugin provides MLB and NCAA Baseball scoreboard functionality by reusing +the proven, working manager classes from the LEDMatrix core project. -Features: -- Multiple league support (MLB, MiLB, NCAA Baseball) -- Live game tracking with innings and time -- Recent game results -- Upcoming game schedules -- Favorite team prioritization -- Background data fetching +Display Modes: +- Switch Mode: Display one game at a time with timed transitions +- Scroll Mode: High-FPS horizontal scrolling of all games with league separators -API Version: 1.0.0 +Sequential Block Display Architecture: +This plugin implements a sequential block display approach where all games from +one league are shown before moving to the next league. This provides: + +1. Predictable Display Order: MLB games show first, then NCAA Baseball games +2. Accurate Dynamic Duration: Duration calculations include all leagues +3. Scalable Design: Easy to add more leagues in the future +4. Granular Control: Support for enabling/disabling at league and mode levels + +The sequential block flow: +- For a display mode (e.g., 'mlb_recent' or 'ncaa_baseball_recent'), get enabled leagues in priority order +- Show all games from the first league (MLB) until complete +- Then show all games from the next league (NCAA Baseball) until complete +- When all enabled leagues complete, the display mode cycle is complete + +This replaces the previous "sticky manager" approach which prevented league rotation +and made it difficult to ensure both leagues were displayed. """ import logging -import os -import threading import time -from datetime import datetime, timezone -from typing import Dict, Any, Optional, List -from pathlib import Path - -import pytz -import requests -from PIL import Image, ImageDraw, ImageFont +from typing import Dict, Any, Set, Optional, Tuple, List -# 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 +from PIL import ImageFont -# Import baseball base classes from LEDMatrix try: - from src.base_classes.baseball import Baseball, BaseballLive - from src.base_classes.sports import SportsRecent, SportsUpcoming + from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode + from src.background_data_service import get_background_service + from src.base_odds_manager import BaseOddsManager except ImportError: - Baseball = None - BaseballLive = None - SportsRecent = None - SportsUpcoming = None - -# Import data manager for background fetching -try: - from data_manager import BaseballDataManager - DATA_MANAGER_AVAILABLE = True -except ImportError: - BaseballDataManager = None - DATA_MANAGER_AVAILABLE = False + BasePlugin = None + VegasDisplayMode = None + get_background_service = None + BaseOddsManager = None + +# Import the copied manager classes +from mlb_managers import MLBLiveManager, MLBRecentManager, MLBUpcomingManager +from ncaa_baseball_managers import ( + NCAABaseballLiveManager, + NCAABaseballRecentManager, + NCAABaseballUpcomingManager, +) +from milb_managers import MiLBLiveManager, MiLBRecentManager, MiLBUpcomingManager # Import scroll display components try: @@ -61,147 +60,100 @@ 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__) -class BaseballScoreboardPlugin(BasePlugin): +class BaseballScoreboardPlugin(BasePlugin if BasePlugin else object): """ - Baseball scoreboard plugin for displaying games across multiple leagues. - - Supports MLB, MiLB, and NCAA Baseball with live, recent, and upcoming game modes. + Baseball scoreboard plugin using existing manager classes. - Configuration options: - leagues: Enable/disable MLB, MiLB, NCAA Baseball - display_modes: Enable live, recent, upcoming modes - favorite_teams: Team abbreviations per league - show_records: Display team records - show_ranking: Display team rankings - background_service: Data fetching configuration + This plugin provides MLB and NCAA Baseball scoreboard functionality by + delegating to the proven manager classes from LEDMatrix core. """ - # ESPN API endpoints for each league - ESPN_API_URLS = { - 'mlb': 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard', - 'milb': 'https://site.api.espn.com/apis/site/v2/sports/baseball/minor-league-baseball/scoreboard', - 'ncaa_baseball': 'https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/scoreboard' - } - - def __init__(self, plugin_id: str, config: Dict[str, Any], - display_manager, cache_manager, plugin_manager): + def __init__( + self, + plugin_id: str, + config: Dict[str, Any], + display_manager, + cache_manager, + plugin_manager, + ): """Initialize the baseball scoreboard plugin.""" - super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) + if BasePlugin: + super().__init__( + plugin_id, config, display_manager, cache_manager, plugin_manager + ) - if Baseball is None: - self.logger.error("Failed to import Baseball base classes. Plugin will not function.") - self.initialized = False - return + self.plugin_id = plugin_id + self.config = config + self.display_manager = display_manager + self.cache_manager = cache_manager + self.plugin_manager = plugin_manager - # Configuration - flattened structure for plugin system compatibility - self.leagues = { - 'mlb': { - 'enabled': config.get('mlb_enabled', True), - 'favorite_teams': config.get('mlb_favorite_teams', []), - 'display_modes': { - 'live': config.get('mlb_display_modes_live', True), - 'recent': config.get('mlb_display_modes_recent', True), - 'upcoming': config.get('mlb_display_modes_upcoming', True) - }, - 'recent_games_to_show': config.get('mlb_recent_games_to_show', 5), - 'upcoming_games_to_show': config.get('mlb_upcoming_games_to_show', 1), - 'background_service': { - 'enabled': config.get('mlb_background_service_enabled', True), - 'max_workers': config.get('mlb_background_service_max_workers', 3), - 'request_timeout': config.get('mlb_background_service_request_timeout', 30), - 'max_retries': config.get('mlb_background_service_max_retries', 3), - 'priority': config.get('mlb_background_service_priority', 2) - } - }, - 'milb': { - 'enabled': config.get('milb_enabled', False), - 'live_priority': config.get('milb_live_priority', False), - 'live_game_duration': config.get('milb_live_game_duration', 30), - 'test_mode': config.get('milb_test_mode', False), - 'update_interval_seconds': config.get('milb_update_interval_seconds', 3600), - 'live_update_interval': config.get('milb_live_update_interval', 30), - 'recent_update_interval': config.get('milb_recent_update_interval', 3600), - 'upcoming_update_interval': config.get('milb_upcoming_update_interval', 3600), - 'recent_games_to_show': config.get('milb_recent_games_to_show', 1), - 'upcoming_games_to_show': config.get('milb_upcoming_games_to_show', 1), - 'favorite_teams': config.get('milb_favorite_teams', []), - 'display_modes': { - 'live': config.get('milb_display_modes_live', True), - 'recent': config.get('milb_display_modes_recent', True), - 'upcoming': config.get('milb_display_modes_upcoming', True) - }, - 'logo_dir': config.get('milb_logo_dir', 'assets/sports/milb_logos'), - 'show_records': config.get('milb_show_records', True), - 'upcoming_fetch_days': config.get('milb_upcoming_fetch_days', 7), - 'background_service': { - 'enabled': config.get('milb_background_service_enabled', True), - 'max_workers': config.get('milb_background_service_max_workers', 3), - 'request_timeout': config.get('milb_background_service_request_timeout', 30), - 'max_retries': config.get('milb_background_service_max_retries', 3), - 'priority': config.get('milb_background_service_priority', 2) - } - }, - 'ncaa_baseball': { - 'enabled': config.get('ncaa_baseball_enabled', False), - 'live_priority': config.get('ncaa_baseball_live_priority', True), - 'live_game_duration': config.get('ncaa_baseball_live_game_duration', 30), - 'show_odds': config.get('ncaa_baseball_show_odds', True), - 'test_mode': config.get('ncaa_baseball_test_mode', False), - 'update_interval_seconds': config.get('ncaa_baseball_update_interval_seconds', 3600), - 'live_update_interval': config.get('ncaa_baseball_live_update_interval', 30), - 'recent_games_to_show': config.get('ncaa_baseball_recent_games_to_show', 1), - 'upcoming_games_to_show': config.get('ncaa_baseball_upcoming_games_to_show', 1), - 'show_favorite_teams_only': config.get('ncaa_baseball_show_favorite_teams_only', True), - 'favorite_teams': config.get('ncaa_baseball_favorite_teams', []), - 'display_modes': { - 'live': config.get('ncaa_baseball_display_modes_live', True), - 'recent': config.get('ncaa_baseball_display_modes_recent', True), - 'upcoming': config.get('ncaa_baseball_display_modes_upcoming', True) - }, - 'logo_dir': config.get('ncaa_baseball_logo_dir', 'assets/sports/ncaa_logos'), - 'show_records': config.get('ncaa_baseball_show_records', True), - 'show_all_live': config.get('ncaa_baseball_show_all_live', False) - } - } + self.logger = logger - # Global settings - self.global_config = config - self.display_duration = config.get('display_duration', 15) - self.show_records = config.get('show_records', False) - self.show_ranking = config.get('show_ranking', False) + # Basic configuration + self.is_enabled = config.get("enabled", True) + # Get display dimensions from display_manager properties + if 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 + else: + self.display_width = getattr(display_manager, "width", 128) + self.display_height = getattr(display_manager, "height", 32) + + # League configurations (defaults come from schema via plugin_manager merge) + # Debug: Log what config we received + self.logger.debug(f"Baseball plugin received config keys: {list(config.keys())}") + self.logger.debug(f"MLB config: {config.get('mlb', {})}") + + self.mlb_enabled = config.get("mlb", {}).get("enabled", False) + self.milb_enabled = config.get("milb", {}).get("enabled", False) + self.ncaa_baseball_enabled = config.get("ncaa_baseball", {}).get("enabled", False) + self.logger.info(f"League enabled states - MLB: {self.mlb_enabled}, MiLB: {self.milb_enabled}, NCAA Baseball: {self.ncaa_baseball_enabled}") - # State - self.current_games = [] - self.current_league = None - self.current_display_mode = None - self.last_update = 0 - # Thread safety lock for shared game state - self._games_lock = threading.RLock() + # League registry: maps league IDs to their configuration and managers + # This structure makes it easy to add more leagues in the future + # Format: {league_id: {'enabled': bool, 'priority': int, 'live_priority': bool, 'managers': {...}}} + # The registry will be populated after managers are initialized + self._league_registry: Dict[str, Dict[str, Any]] = {} + + # Global settings + self.display_duration = float(config.get("display_duration", 30)) + self.game_display_duration = float(config.get("game_display_duration", 15)) + + # Live priority per league + self.mlb_live_priority = self.config.get("mlb", {}).get("live_priority", False) + self.milb_live_priority = self.config.get("milb", {}).get("live_priority", False) + self.ncaa_baseball_live_priority = self.config.get("ncaa_baseball", {}).get( + "live_priority", False + ) + + # Display mode settings per league and game type + self._display_mode_settings = self._parse_display_mode_settings() + # Initialize background service if available + self.background_service = None + if get_background_service: + try: + self.background_service = get_background_service( + self.cache_manager, max_workers=1 + ) + self.logger.info("Background service initialized") + except Exception as e: + self.logger.warning(f"Could not initialize background service: {e}") + + # Initialize managers + self._initialize_managers() + + # Initialize league registry after managers are created + # This centralizes league management and makes it easy to add more leagues + self._initialize_league_registry() + # Initialize scroll display manager if available - self._scroll_manager = None + self._scroll_manager: Optional[ScrollDisplayManager] = None if SCROLL_AVAILABLE and ScrollDisplayManager: try: self._scroll_manager = ScrollDisplayManager( @@ -209,1269 +161,3463 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self.config, self.logger ) - self.logger.info("Baseball scroll display manager initialized") + self.logger.info("Scroll display manager initialized") except Exception as e: self.logger.warning(f"Could not initialize scroll display manager: {e}") + self._scroll_manager = None else: - self.logger.info("Scroll display not available - scroll mode disabled") - - # Initialize odds manager - self._odds_manager = None - if BaseballOddsManager: - try: - self._odds_manager = BaseballOddsManager(cache_manager, logger=self.logger) - self.logger.info("Baseball odds manager initialized") - except Exception as e: - self.logger.warning(f"Could not initialize odds manager: {e}") + self.logger.debug("Scroll mode not available - ScrollDisplayManager not imported") + + # Track current scroll state + self._scroll_active: Dict[str, bool] = {} # {game_type: is_active} + self._scroll_prepared: Dict[str, bool] = {} # {game_type: is_prepared} + + # Enable high-FPS mode for scroll display (allows 100+ FPS scrolling) + # This signals to the display controller to use high-FPS loop (8ms = 125 FPS) + self.enable_scrolling = self._scroll_manager is not None + if self.enable_scrolling: + self.logger.info("High-FPS scrolling enabled for baseball scoreboard") + + # Mode cycling + self.current_mode_index = 0 + self.last_mode_switch = 0 + self.modes = self._get_available_modes() + + self.logger.info( + f"Baseball scoreboard plugin initialized - {self.display_width}x{self.display_height}" + ) + self.logger.info( + f"MLB enabled: {self.mlb_enabled}, MiLB enabled: {self.milb_enabled}, NCAA Baseball enabled: {self.ncaa_baseball_enabled}" + ) - # 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}") + # Dynamic duration tracking + self._dynamic_cycle_seen_modes: Set[str] = set() + self._dynamic_mode_to_manager_key: Dict[str, str] = {} + self._dynamic_manager_progress: Dict[str, Set[str]] = {} + self._dynamic_managers_completed: Set[str] = set() + self._dynamic_cycle_complete = False + # Track when single-game managers were first seen to ensure full duration + self._single_game_manager_start_times: Dict[str, float] = {} + # Track when each game ID was first seen to ensure full per-game duration + # Using game IDs instead of indices prevents start time resets when game order changes + self._game_id_start_times: Dict[str, Dict[str, float]] = {} # {manager_key: {game_id: start_time}} + # Track which managers were actually used for each display mode + self._display_mode_to_managers: Dict[str, Set[str]] = {} # {display_mode: {manager_key, ...}} + + # Track current display context for granular dynamic duration + self._current_display_league: Optional[str] = None # 'mlb' or 'ncaa_baseball' + self._current_display_mode_type: Optional[str] = None # 'live', 'recent', 'upcoming' + + # Throttle logging for has_live_content() when returning False + self._last_live_content_false_log: float = 0.0 # Timestamp of last False log + self._live_content_log_interval: float = 60.0 # Log False results every 60 seconds + + # Track last display mode to detect when we return after being away + self._last_display_mode: Optional[str] = None # Track previous display mode + self._last_display_mode_time: float = 0.0 # When we last saw this mode + self._current_active_display_mode: Optional[str] = None # Currently active external display mode + + # Track current game for transition detection + # Format: {display_mode: {'game_id': str, 'league': str, 'last_log_time': float}} + self._current_game_tracking: Dict[str, Dict[str, Any]] = {} + self._game_transition_log_interval: float = 1.0 # Minimum seconds between game transition logs + + # Track mode start times for per-mode duration enforcement + # Format: {display_mode: start_time} (e.g., {'mlb_recent': 1234567890.0}) + # Reset when mode changes or full cycle completes + self._mode_start_time: Dict[str, float] = {} + + # Note: Sticky manager tracking has been removed in favor of sequential block display + # Sequential block display shows all games from one league before moving to the next, + # which is simpler and more predictable than the sticky manager approach - # 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}") + def _initialize_managers(self): + """Initialize all manager instances.""" + try: + # Create adapted configs for managers + mlb_config = self._adapt_config_for_manager("mlb") + milb_config = self._adapt_config_for_manager("milb") + ncaa_baseball_config = self._adapt_config_for_manager("ncaa_baseball") + + # Initialize MLB managers if enabled + if self.mlb_enabled: + self.mlb_live = MLBLiveManager( + mlb_config, self.display_manager, self.cache_manager + ) + self.mlb_recent = MLBRecentManager( + mlb_config, self.display_manager, self.cache_manager + ) + self.mlb_upcoming = MLBUpcomingManager( + mlb_config, self.display_manager, self.cache_manager + ) + self.logger.info("MLB managers initialized") - self.initialized = True + # Initialize MiLB managers if enabled + if self.milb_enabled: + self.milb_live = MiLBLiveManager( + milb_config, self.display_manager, self.cache_manager + ) + self.milb_recent = MiLBRecentManager( + milb_config, self.display_manager, self.cache_manager + ) + self.milb_upcoming = MiLBUpcomingManager( + milb_config, self.display_manager, self.cache_manager + ) + self.logger.info("MiLB managers initialized") - # Initialize data manager for background fetching - self.data_manager = None - if DATA_MANAGER_AVAILABLE: - try: - self.data_manager = BaseballDataManager(cache_manager, self.logger) - self.logger.info("Baseball data manager initialized with background service support") - except Exception as e: - self.logger.warning(f"Could not initialize data manager, using sync fetching: {e}") + # Initialize NCAA Baseball managers if enabled + if self.ncaa_baseball_enabled: + self.ncaa_baseball_live = NCAABaseballLiveManager( + ncaa_baseball_config, self.display_manager, self.cache_manager + ) + self.ncaa_baseball_recent = NCAABaseballRecentManager( + ncaa_baseball_config, self.display_manager, self.cache_manager + ) + self.ncaa_baseball_upcoming = NCAABaseballUpcomingManager( + ncaa_baseball_config, self.display_manager, self.cache_manager + ) + self.logger.info("NCAA Baseball managers initialized") - # Load fonts for rendering - self.fonts = self._load_fonts() + except Exception as e: + self.logger.error(f"Error initializing managers: {e}", exc_info=True) - # Register fonts - self._register_fonts() + def _initialize_league_registry(self) -> None: + """ + Initialize the league registry with all available leagues. + + The league registry centralizes league management and makes it easy to: + - Add new leagues in the future (just add an entry here) + - Query enabled leagues for a mode type + - Get managers in priority order + - Check league completion status + + Registry format: + { + 'league_id': { + 'enabled': bool, # Whether the league is enabled + 'priority': int, # Display priority (lower = higher priority) + 'live_priority': bool, # Whether live priority is enabled for this league + 'managers': { + 'live': Manager or None, + 'recent': Manager or None, + 'upcoming': Manager or None + } + } + } + + This design allows the display logic to iterate through leagues in priority + order without hardcoding league names throughout the codebase. + """ + # MLB league entry - highest priority (1) + # Note: We normalize league IDs to use consistent naming ('mlb', 'ncaa_baseball') + # even though managers may use different internal identifiers + self._league_registry['mlb'] = { + 'enabled': self.mlb_enabled, + 'priority': 1, # Highest priority - shows first + 'live_priority': self.mlb_live_priority, + 'managers': { + 'live': getattr(self, 'mlb_live', None), + 'recent': getattr(self, 'mlb_recent', None), + 'upcoming': getattr(self, 'mlb_upcoming', None), + } + } + + # MiLB league entry - second priority (2) + self._league_registry['milb'] = { + 'enabled': self.milb_enabled, + 'priority': 2, # Second priority - shows after MLB + 'live_priority': self.milb_live_priority, + 'managers': { + 'live': getattr(self, 'milb_live', None), + 'recent': getattr(self, 'milb_recent', None), + 'upcoming': getattr(self, 'milb_upcoming', None), + } + } - # Log enabled leagues and their settings + # NCAA Baseball league entry - third priority (3) + self._league_registry['ncaa_baseball'] = { + 'enabled': self.ncaa_baseball_enabled, + 'priority': 3, # Third priority - shows after MiLB + 'live_priority': self.ncaa_baseball_live_priority, + 'managers': { + 'live': getattr(self, 'ncaa_baseball_live', None), + 'recent': getattr(self, 'ncaa_baseball_recent', None), + 'upcoming': getattr(self, 'ncaa_baseball_upcoming', None), + } + } + + # Log registry state for debugging + enabled_leagues = [lid for lid, data in self._league_registry.items() if data['enabled']] + self.logger.info( + f"League registry initialized: {len(self._league_registry)} league(s) registered, " + f"{len(enabled_leagues)} enabled: {enabled_leagues}" + ) + + # Future leagues can be added here following the same pattern: + # self._league_registry['xfl'] = { + # 'enabled': self.config.get('xfl', {}).get('enabled', False), + # 'priority': 3, + # 'live_priority': self.config.get('xfl', {}).get('live_priority', False), + # 'managers': { + # 'live': getattr(self, 'xfl_live', None), + # 'recent': getattr(self, 'xfl_recent', None), + # 'upcoming': getattr(self, 'xfl_upcoming', None), + # } + # } + + def _get_enabled_leagues_for_mode(self, mode_type: str) -> List[str]: + """ + Get list of enabled leagues for a specific mode type in priority order. + + This method respects both league-level and mode-level disabling: + - League must be enabled (league.enabled = True) + - Mode must be enabled for that league (league.display_modes.show_ = True) + + Args: + mode_type: Mode type ('live', 'recent', or 'upcoming') + + Returns: + List of league IDs in priority order (lower priority number = higher priority) + Example: ['mlb', 'ncaa_baseball'] means MLB shows first, then NCAA Baseball + + This is the core method for sequential block display - it determines + which leagues should be shown and in what order. + """ enabled_leagues = [] - for league_key, league_config in self.leagues.items(): - if league_config.get('enabled', False): - enabled_leagues.append(league_key) + + # Iterate through all registered leagues + for league_id, league_data in self._league_registry.items(): + # Check if league is enabled + if not league_data.get('enabled', False): + continue + + # Check if this mode type is enabled for this league + # Get the league config to check display_modes settings + league_config = self.config.get(league_id, {}) + display_modes_config = league_config.get("display_modes", {}) + + # Check the appropriate flag based on mode type + mode_enabled = True # Default to enabled if not specified + if mode_type == 'live': + mode_enabled = display_modes_config.get("show_live", True) + elif mode_type == 'recent': + mode_enabled = display_modes_config.get("show_recent", True) + elif mode_type == 'upcoming': + mode_enabled = display_modes_config.get("show_upcoming", True) + + # Only include if mode is enabled for this league + if mode_enabled: + enabled_leagues.append(league_id) + + # Sort by priority (lower number = higher priority) + enabled_leagues.sort(key=lambda lid: self._league_registry[lid].get('priority', 999)) + + self.logger.debug( + f"Enabled leagues for {mode_type} mode: {enabled_leagues} " + f"(priorities: {[self._league_registry[lid].get('priority') for lid in enabled_leagues]})" + ) + + return enabled_leagues - self.logger.info("Baseball scoreboard plugin initialized") - self.logger.info(f"Enabled leagues: {enabled_leagues}") + def _is_league_complete_for_mode(self, league_id: str, mode_type: str) -> bool: + """ + Check if a league has completed showing all games for a specific mode type. + + This is used in sequential block display to determine when to move from + one league to the next. A league is considered complete when all its games + have been shown for their full duration (tracked via dynamic duration system). + + Args: + league_id: League identifier ('mlb', 'ncaa_baseball', etc.) + mode_type: Mode type ('live', 'recent', or 'upcoming') + + Returns: + True if the league's manager for this mode is marked as complete, + False otherwise + + The completion status is tracked in _dynamic_managers_completed set, + using manager keys in the format: "{league_id}_{mode_type}:ManagerClass" + """ + # Get the manager for this league and mode + manager = self._get_league_manager_for_mode(league_id, mode_type) + if not manager: + # No manager means league can't be displayed, so consider it "complete" + # (nothing to show, so we can move on) + return True + + # Build the manager key that matches what's used in progress tracking + # Format: "{league_id}_{mode_type}:ManagerClass" + manager_key = self._build_manager_key(f"{league_id}_{mode_type}", manager) + + # Check if this manager is in the completed set + is_complete = manager_key in self._dynamic_managers_completed + + if is_complete: + self.logger.debug(f"League {league_id} {mode_type} is complete (manager_key: {manager_key})") + else: + self.logger.debug(f"League {league_id} {mode_type} is not complete (manager_key: {manager_key})") + + return is_complete - def _load_fonts(self): - """Load fonts used by the scoreboard - matching original managers.""" - 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 as e: - self.logger.warning(f"Fonts not found, using default PIL font: {e}") - 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 _register_fonts(self): - """Register fonts with the font manager.""" - try: - if not hasattr(self.plugin_manager, 'font_manager'): - return + def _get_league_manager_for_mode(self, league_id: str, mode_type: str): + """ + Get the manager instance for a specific league and mode type. + + This is a convenience method that looks up managers from the league registry. + It provides a single point of access for getting managers, making the code + more maintainable and easier to extend. + + Args: + league_id: League identifier ('mlb', 'ncaa_baseball', etc.) + mode_type: Mode type ('live', 'recent', or 'upcoming') + + Returns: + Manager instance if found, None otherwise + + The manager is retrieved from the league registry, which is populated + during initialization. If the league or mode doesn't exist, returns None. + """ + # Check if league exists in registry + if league_id not in self._league_registry: + self.logger.warning(f"League {league_id} not found in registry") + return None + + # Get managers dict for this league + managers = self._league_registry[league_id].get('managers', {}) + + # Get the manager for this mode type + manager = managers.get(mode_type) + + if manager is None: + self.logger.debug(f"No manager found for {league_id} {mode_type}") + + return manager - font_manager = self.plugin_manager.font_manager + def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: + """ + Adapt plugin config format to manager expected format. - # Team name font - font_manager.register_manager_font( - manager_id=self.plugin_id, - element_key=f"{self.plugin_id}.team_name", - family="press_start", - size_px=10, - color=(255, 255, 255) - ) + Plugin uses: mlb: {...}, milb: {...}, ncaa_baseball: {...} + Managers expect: mlb_scoreboard: {...}, milb_scoreboard: {...}, ncaa_baseball_scoreboard: {...} + """ + league_config = self.config.get(league, {}) + + # Debug: Log the entire league_config to see what we're actually getting + self.logger.debug(f"DEBUG: league_config for {league} = {league_config}") + + # Extract nested configurations + game_limits = league_config.get("game_limits", {}) + display_options = league_config.get("display_options", {}) + filtering = league_config.get("filtering", {}) + display_modes_config = league_config.get("display_modes", {}) + + manager_display_modes = { + f"{league}_live": display_modes_config.get("show_live", True), + f"{league}_recent": display_modes_config.get("show_recent", True), + f"{league}_upcoming": display_modes_config.get("show_upcoming", True), + } - # Score font - font_manager.register_manager_font( - manager_id=self.plugin_id, - element_key=f"{self.plugin_id}.score", - family="press_start", - size_px=12, - color=(255, 200, 0) - ) + # Explicitly check if keys exist, not just if they're truthy + # This handles False values correctly (False is a valid saved value) + # Priority: filtering dict first (more reliable), then top-level, then default + if "show_favorite_teams_only" in filtering: + show_favorites_only = filtering["show_favorite_teams_only"] + elif "show_favorite_teams_only" in league_config: + show_favorites_only = league_config["show_favorite_teams_only"] + elif "favorite_teams_only" in league_config: + show_favorites_only = league_config["favorite_teams_only"] + else: + # Default to False if not specified (schema default is True, but we want False as default) + show_favorites_only = False + + # Debug logging to diagnose config reading issues + self.logger.debug( + f"Config reading for {league}: " + f"league_config.show_favorite_teams_only={league_config.get('show_favorite_teams_only', 'NOT_SET')}, " + f"filtering.show_favorite_teams_only={filtering.get('show_favorite_teams_only', 'NOT_SET')}, " + f"final show_favorites_only={show_favorites_only}" + ) - # Status font (inning, time) - font_manager.register_manager_font( - manager_id=self.plugin_id, - element_key=f"{self.plugin_id}.status", - family="four_by_six", - size_px=6, - color=(0, 255, 0) - ) + # Explicitly check if key exists for show_all_live + # Priority: filtering dict first (more reliable), then top-level, then default + if "show_all_live" in filtering: + show_all_live = filtering["show_all_live"] + elif "show_all_live" in league_config: + show_all_live = league_config["show_all_live"] + else: + # Default to False if not specified + show_all_live = False + + # Debug logging for show_all_live + self.logger.debug( + f"Config reading for {league}: " + f"league_config.show_all_live={league_config.get('show_all_live', 'NOT_SET')}, " + f"filtering.show_all_live={filtering.get('show_all_live', 'NOT_SET')}, " + f"final show_all_live={show_all_live}" + ) - # Detail font (records, rankings) - font_manager.register_manager_font( - manager_id=self.plugin_id, - element_key=f"{self.plugin_id}.detail", - family="four_by_six", - size_px=6, - color=(200, 200, 200) - ) + # Create manager config with expected structure + manager_config = { + f"{league}_scoreboard": { + "enabled": league_config.get("enabled", False), + "favorite_teams": league_config.get("favorite_teams", []), + "display_modes": manager_display_modes, + "recent_games_to_show": game_limits.get("recent_games_to_show", 5), + "upcoming_games_to_show": game_limits.get("upcoming_games_to_show", 10), + "show_records": display_options.get("show_records", False), + "show_ranking": display_options.get("show_ranking", False), + "show_odds": display_options.get("show_odds", False), + "update_interval_seconds": league_config.get( + "update_interval_seconds", 300 + ), + "live_update_interval": league_config.get("live_update_interval", 30), + "live_game_duration": league_config.get("live_game_duration", 20), + "recent_game_duration": league_config.get( + "recent_game_duration", + 15 # Default per-game duration for recent games + ), + "upcoming_game_duration": league_config.get( + "upcoming_game_duration", + 15 # Default per-game duration for upcoming games + ), + "live_priority": league_config.get("live_priority", False), + "show_favorite_teams_only": show_favorites_only, + "show_all_live": show_all_live, + "filtering": filtering, + "background_service": { + "request_timeout": 30, + "max_retries": 3, + "priority": 2, + }, + } + } - self.logger.info("Baseball scoreboard fonts registered") - except Exception as e: - self.logger.warning(f"Error registering fonts: {e}") + # Add global config - get timezone from cache_manager's config_manager if available + timezone_str = self.config.get("timezone") + if not timezone_str and hasattr(self.cache_manager, 'config_manager'): + timezone_str = self.cache_manager.config_manager.get_timezone() + if not timezone_str: + timezone_str = "UTC" + + # Get display config from main config if available + display_config = self.config.get("display", {}) + if not display_config and hasattr(self.cache_manager, 'config_manager'): + display_config = self.cache_manager.config_manager.get_display_config() + + # Get customization config from main config (shared across all leagues) + customization_config = self.config.get("customization", {}) + + manager_config.update( + { + "timezone": timezone_str, + "display": display_config, + "customization": customization_config, + } + ) + + self.logger.debug(f"Using timezone: {timezone_str} for {league} managers") + + return manager_config + + def _parse_display_mode_settings(self) -> Dict[str, Dict[str, str]]: + """ + Parse display mode settings from config. + + Returns: + Dict mapping league -> game_type -> display_mode ('switch' or 'scroll') + e.g., {'mlb': {'live': 'switch', 'recent': 'scroll', 'upcoming': 'scroll'}} + """ + settings = {} + + for league in ['mlb', 'milb', 'ncaa_baseball']: + league_config = self.config.get(league, {}) + display_modes_config = league_config.get("display_modes", {}) + + settings[league] = { + 'live': display_modes_config.get('live_display_mode', 'switch'), + 'recent': display_modes_config.get('recent_display_mode', 'switch'), + 'upcoming': display_modes_config.get('upcoming_display_mode', 'switch'), + } + + self.logger.debug(f"Display mode settings for {league}: {settings[league]}") + + return settings + + def _get_display_mode(self, league: str, game_type: str) -> str: + """ + Get the display mode for a specific league and game type. + + Args: + league: 'mlb', 'milb', or 'ncaa_baseball' + game_type: 'live', 'recent', or 'upcoming' + + Returns: + 'switch' or 'scroll' + """ + return self._display_mode_settings.get(league, {}).get(game_type, 'switch') + + def _should_use_scroll_mode(self, mode_type: str) -> bool: + """ + Check if ANY enabled league should use scroll mode for this game type. + + This determines if we should collect games for scrolling or use switch mode. + + Args: + mode_type: 'live', 'recent', or 'upcoming' + + Returns: + True if at least one enabled league uses scroll mode for this game type + """ + if self.mlb_enabled and self._get_display_mode('mlb', mode_type) == 'scroll': + return True - def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: + if self.milb_enabled and self._get_display_mode('milb', mode_type) == 'scroll': + return True + if self.ncaa_baseball_enabled and self._get_display_mode('ncaa_baseball', mode_type) == 'scroll': + return True + return False + + def _collect_games_for_scroll( + self, + mode_type: Optional[str] = None, + live_priority_active: bool = False + ) -> Tuple[List[Dict], List[str]]: """ - Get layout offset for a specific element and axis. + Collect all games from enabled leagues for scroll mode. Args: - element: Element name (e.g., 'home_logo', 'away_logo', 'score', 'status') - axis: 'x_offset' or 'y_offset' - default: Default value if not configured (default: 0) + mode_type: Optional game type filter ('live', 'recent', 'upcoming'). + If None, collects all game types organized by league. + live_priority_active: If True, only include live games Returns: - Offset value from config or default (always returns int) + Tuple of (games list with league info, list of leagues included) """ + games = [] + leagues = [] + + # Determine which mode types to collect + if mode_type is None: + # Collect all game types for Vegas mode + mode_types = ['live', 'recent', 'upcoming'] + else: + # Collect single game type for internal plugin scroll mode + mode_types = [mode_type] + + # Collect MLB games if enabled + if self.mlb_enabled: + league_games = [] + for mt in mode_types: + # Check if scroll mode is enabled for this league/mode + if self._get_display_mode('mlb', mt) == 'scroll': + league_manager = self._get_manager_for_league_mode('mlb', mt) + if league_manager: + league_games_list = self._get_games_from_manager(league_manager, mt) + if league_games_list: + # Add league info and ensure status field + for game in league_games_list: + game['league'] = 'mlb' + # Ensure game has status dict for type determination + if not isinstance(game.get('status'), dict): + game['status'] = {} + if 'state' not in game['status']: + # Infer state from mode_type + state_map = {'live': 'in', 'recent': 'post', 'upcoming': 'pre'} + game['status']['state'] = state_map.get(mt, 'pre') + league_games.extend(league_games_list) + self.logger.debug(f"Collected {len(league_games_list)} MLB {mt} games for scroll") + + if league_games: + games.extend(league_games) + leagues.append('mlb') + + # Collect NCAA Baseball games if enabled + + if self.milb_enabled: + league_games = [] + for mt in mode_types: + # Check if scroll mode is enabled for this league/mode + if self._get_display_mode('milb', mt) == 'scroll': + league_manager = self._get_manager_for_league_mode('milb', mt) + if league_manager: + league_games_list = self._get_games_from_manager(league_manager, mt) + if league_games_list: + # Add league info and ensure status field + for game in league_games_list: + game['league'] = 'milb' + # Ensure game has status dict for type determination + if not isinstance(game.get('status'), dict): + game['status'] = {} + if 'state' not in game['status']: + # Infer state from mode_type + state_map = {'live': 'in', 'recent': 'post', 'upcoming': 'pre'} + game['status']['state'] = state_map.get(mt, 'pre') + league_games.extend(league_games_list) + self.logger.debug(f"Collected {len(league_games_list)} MiLB {mt} games for scroll") + + if league_games: + games.extend(league_games) + leagues.append('milb') + + # Collect NCAA Baseball games if enabled + if self.ncaa_baseball_enabled: + league_games = [] + for mt in mode_types: + # Check if scroll mode is enabled for this league/mode + if self._get_display_mode('ncaa_baseball', mt) == 'scroll': + ncaa_manager = self._get_manager_for_league_mode('ncaa_baseball', mt) + if ncaa_manager: + ncaa_games = self._get_games_from_manager(ncaa_manager, mt) + if ncaa_games: + # Add league info and ensure status field + for game in ncaa_games: + game['league'] = 'ncaa_baseball' + # Ensure game has status dict for type determination + if not isinstance(game.get('status'), dict): + game['status'] = {} + if 'state' not in game['status']: + # Infer state from mode_type + state_map = {'live': 'in', 'recent': 'post', 'upcoming': 'pre'} + game['status']['state'] = state_map.get(mt, 'pre') + league_games.extend(ncaa_games) + self.logger.debug(f"Collected {len(ncaa_games)} NCAA Baseball {mt} games for scroll") + + if league_games: + games.extend(league_games) + leagues.append('ncaa_baseball') + + # If live priority is active, filter to only live games + if live_priority_active: + games = [g for g in games if g.get('is_live', False) and not g.get('is_final', False)] + self.logger.debug(f"Live priority active: filtered to {len(games)} live games") + + return games, leagues + + def _get_games_from_manager(self, manager, mode_type: str) -> List[Dict]: + """Get games list from a manager based on mode type.""" + if mode_type == 'live': + return list(getattr(manager, 'live_games', []) or []) + elif mode_type == 'recent': + # Try games_list first (used by recent managers), then recent_games + games = getattr(manager, 'games_list', None) + if games is None: + games = getattr(manager, 'recent_games', []) + return list(games or []) + elif mode_type == 'upcoming': + # Try games_list first (used by upcoming managers), then upcoming_games + games = getattr(manager, 'games_list', None) + if games is None: + games = getattr(manager, 'upcoming_games', []) + return list(games or []) + return [] + + def _get_rankings_cache(self) -> Dict[str, int]: + """Get combined team rankings cache from all managers.""" + rankings = {} + + # Try to get rankings from each manager + for manager_attr in ['mlb_live', 'mlb_recent', 'mlb_upcoming', + 'ncaa_baseball_live', 'ncaa_baseball_recent', 'ncaa_baseball_upcoming']: + manager = getattr(self, manager_attr, None) + if manager: + manager_rankings = getattr(manager, '_team_rankings_cache', {}) + if manager_rankings: + rankings.update(manager_rankings) + + return rankings + + def _get_available_modes(self) -> list: + """Get list of available display modes based on enabled leagues.""" + modes = [] + + def league_modes(league: str) -> Dict[str, bool]: + league_config = self.config.get(league, {}) + display_modes = league_config.get("display_modes", {}) + return { + "live": display_modes.get("show_live", True), + "recent": display_modes.get("show_recent", True), + "upcoming": display_modes.get("show_upcoming", True), + } + + if self.mlb_enabled: + flags = league_modes("mlb") + prefix = "mlb" + if flags["live"]: + modes.append(f"{prefix}_live") + if flags["recent"]: + modes.append(f"{prefix}_recent") + if flags["upcoming"]: + modes.append(f"{prefix}_upcoming") + + + if self.milb_enabled: + flags = league_modes("milb") + prefix = "milb" + if flags["live"]: + modes.append(f"{prefix}_live") + if flags["recent"]: + modes.append(f"{prefix}_recent") + if flags["upcoming"]: + modes.append(f"{prefix}_upcoming") + + if self.ncaa_baseball_enabled: + flags = league_modes("ncaa_baseball") + prefix = "ncaa_baseball" + if flags["live"]: + modes.append(f"{prefix}_live") + if flags["recent"]: + modes.append(f"{prefix}_recent") + if flags["upcoming"]: + modes.append(f"{prefix}_upcoming") + + # Default to MLB if no leagues enabled + if not modes: + modes = ["mlb_live", "mlb_recent", "mlb_upcoming"] + + return modes + + def _get_current_manager(self): + """Get the current manager based on the current mode.""" + if not self.modes: + return None + + current_mode = self.modes[self.current_mode_index] + + if current_mode.startswith("mlb_"): + if not self.mlb_enabled: + return None + mode_type = current_mode.split("_", 1)[1] # "live", "recent", "upcoming" + if mode_type == "live": + return self.mlb_live + elif mode_type == "recent": + return self.mlb_recent + elif mode_type == "upcoming": + return self.mlb_upcoming + + elif current_mode.startswith("milb_"): + if not self.milb_enabled: + return None + mode_type = current_mode.split("_", 1)[1] # "live", "recent", "upcoming" + if mode_type == "live": + return self.milb_live + elif mode_type == "recent": + return self.milb_recent + elif mode_type == "upcoming": + return self.milb_upcoming + + elif current_mode.startswith("ncaa_baseball_"): + if not self.ncaa_baseball_enabled: + return None + mode_type = current_mode.split("_", 2)[2] # "live", "recent", "upcoming" + if mode_type == "live": + return self.ncaa_baseball_live + elif mode_type == "recent": + return self.ncaa_baseball_recent + elif mode_type == "upcoming": + return self.ncaa_baseball_upcoming + + return None + + def _ensure_manager_updated(self, manager) -> None: + """Trigger an update when the delegated manager is stale.""" + last_update = getattr(manager, "last_update", None) + update_interval = getattr(manager, "update_interval", None) + if last_update is None or update_interval is None: + return + + interval = update_interval + no_data_interval = getattr(manager, "no_data_interval", None) + live_games = getattr(manager, "live_games", None) + if no_data_interval and not live_games: + interval = no_data_interval + try: - layout_config = self.config.get('customization', {}).get('layout', {}) - element_config = layout_config.get(element, {}) - offset_value = element_config.get(axis, default) - - # Ensure we return an integer (handle float/string from config) - if isinstance(offset_value, (int, float)): - return int(offset_value) - elif isinstance(offset_value, str): - try: - return int(float(offset_value)) - except ValueError: - self.logger.warning(f"Invalid offset value '{offset_value}' for {element}.{axis}, using default {default}") - return default - else: - return default - except Exception as e: - self.logger.debug(f"Error getting layout offset for {element}.{axis}: {e}") - return default + if interval and time.time() - last_update >= interval: + manager.update() + except Exception as exc: + self.logger.debug(f"Auto-refresh failed for manager {manager}: {exc}") def update(self) -> None: - """Update baseball game data for all enabled leagues.""" - if not self.initialized: + """Update baseball game data.""" + if not self.is_enabled: return try: - # Fetch data for each enabled league (outside lock) - new_games = [] - for league_key, league_config in self.leagues.items(): - if league_config.get('enabled', False): - games = self._fetch_league_data(league_key, league_config) - if games: - # Add league info to each game - for game in games: - 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 - # Sort games - prioritize live games and favorites - self._sort_games() - self.last_update = time.time() - - self.logger.debug(f"Updated baseball data: {len(self.current_games)} games") + # Update MLB managers if enabled + if self.mlb_enabled: + self.mlb_live.update() + self.mlb_recent.update() + self.mlb_upcoming.update() + + # Update MiLB managers if enabled + if self.milb_enabled: + self.milb_live.update() + self.milb_recent.update() + self.milb_upcoming.update() + + # Update NCAA Baseball managers if enabled + if self.ncaa_baseball_enabled: + self.ncaa_baseball_live.update() + self.ncaa_baseball_recent.update() + self.ncaa_baseball_upcoming.update() except Exception as e: - self.logger.error(f"Error updating baseball data: {e}") + self.logger.error(f"Error updating managers: {e}") - def _sort_games(self): - """Sort games by priority and favorites.""" - def sort_key(game): - # Priority 1: Live games - live_score = 0 if game.get('is_live') else 1 + def _get_managers_in_priority_order(self, mode_type: str) -> list: + """ + Get managers for a mode type in priority order based on league registry. + + This method replaces the old sticky manager logic with a simpler approach: + - Returns managers in priority order (MLB first, then NCAA Baseball, etc.) + - Sequential block display logic handles showing all games from one league + before moving to the next + - No sticky manager state needed - completion is tracked via dynamic duration + + Args: + mode_type: Mode type ('live', 'recent', or 'upcoming') + + Returns: + List of manager instances in priority order (highest priority first) + Managers are filtered to only include enabled leagues with the mode enabled + + This is used by the sequential block display logic to determine which + leagues should be shown and in what order. + """ + managers = [] + + # Get enabled leagues for this mode type in priority order + enabled_leagues = self._get_enabled_leagues_for_mode(mode_type) + + # Get managers for each enabled league in priority order + for league_id in enabled_leagues: + manager = self._get_league_manager_for_mode(league_id, mode_type) + if manager: + managers.append(manager) + self.logger.debug( + f"Added {league_id} {mode_type} manager to priority list " + f"(priority: {self._league_registry[league_id].get('priority', 999)})" + ) + + self.logger.debug( + f"Managers in priority order for {mode_type}: " + f"{[m.__class__.__name__ for m in managers]}" + ) + + return managers + + def _try_manager_display( + self, + manager, + force_clear: bool, + display_mode: str, + mode_type: str, + sticky_manager=None # Kept for compatibility but no longer used + ) -> Tuple[bool, Optional[str]]: + """ + Try to display content from a single manager. + + This method handles displaying content from a manager and tracking progress + for dynamic duration. It no longer uses sticky manager logic - sequential + block display handles league rotation at a higher level. + + Args: + manager: Manager instance to try + force_clear: Whether to force clear display + display_mode: External display mode name (e.g., 'mlb_recent' or 'ncaa_baseball_recent') + mode_type: Mode type ('live', 'recent', or 'upcoming') + sticky_manager: Deprecated parameter (kept for compatibility, ignored) + + Returns: + Tuple of (success: bool, actual_mode: Optional[str]) + - success: True if manager displayed content, False otherwise + - actual_mode: The actual mode name used for tracking (e.g., 'mlb_recent') + """ + if not manager: + return False, None + + # Track which league we're displaying for granular dynamic duration + # This sets _current_display_league and _current_display_mode_type + # which are used for progress tracking and duration calculations + self._set_display_context_from_manager(manager, mode_type) + + # Ensure manager is updated before displaying + # This fetches fresh data if needed based on update intervals + self._ensure_manager_updated(manager) + + # Attempt to display content from this manager + # Manager returns True if it has content to show, False if no content + result = manager.display(force_clear) + + # Build the actual mode name from league and mode_type for accurate tracking + # This is used to track progress per league separately + # Example: 'mlb_recent' or 'ncaa_baseball_live' + actual_mode = ( + f"{self._current_display_league}_{mode_type}" + if self._current_display_league and mode_type + else display_mode + ) + + # Track game transitions for logging + # Only log at DEBUG level for frequent calls, INFO for game transitions + manager_class_name = manager.__class__.__name__ + has_current_game = hasattr(manager, 'current_game') and manager.current_game is not None + current_game = getattr(manager, 'current_game', None) if has_current_game else None + + # Get current game ID for transition detection + current_game_id = None + if current_game: + current_game_id = current_game.get('id') or current_game.get('game_id') + if not current_game_id: + # Fallback: create ID from team abbreviations + away = current_game.get('away_abbr', '') + home = current_game.get('home_abbr', '') + if away and home: + current_game_id = f"{away}@{home}" + + # Check for game transition + game_tracking = self._current_game_tracking.get(display_mode, {}) + last_game_id = game_tracking.get('game_id') + last_league = game_tracking.get('league') + last_log_time = game_tracking.get('last_log_time', 0.0) + current_time = time.time() + + # Detect game transition or league change + game_changed = (current_game_id and current_game_id != last_game_id) + league_changed = (self._current_display_league and self._current_display_league != last_league) + time_since_last_log = current_time - last_log_time + + # Log game transitions at INFO level (but throttle to avoid spam) + if (game_changed or league_changed) and time_since_last_log >= self._game_transition_log_interval: + if game_changed and current_game_id: + away_abbr = current_game.get('away_abbr', '?') if current_game else '?' + home_abbr = current_game.get('home_abbr', '?') if current_game else '?' + self.logger.info( + f"Game transition in {display_mode}: " + f"{away_abbr} @ {home_abbr} " + f"({self._current_display_league or 'unknown'} {mode_type})" + ) + elif league_changed and self._current_display_league: + self.logger.info( + f"League transition in {display_mode}: " + f"switched to {self._current_display_league} {mode_type}" + ) + + # Update tracking + self._current_game_tracking[display_mode] = { + 'game_id': current_game_id, + 'league': self._current_display_league, + 'last_log_time': current_time + } + else: + # Frequent calls - only log at DEBUG level + self.logger.debug( + f"Manager {manager_class_name} display() returned {result}, " + f"has_current_game={has_current_game}, game_id={current_game_id}" + ) + + if result is True: + # Manager successfully displayed content + # Track progress for dynamic duration system + manager_key = self._build_manager_key(actual_mode, manager) + + try: + # Record that we've seen this manager and track game progress + # This updates _dynamic_manager_progress and marks games as shown + self._record_dynamic_progress(manager, actual_mode=actual_mode, display_mode=display_mode) + except Exception as progress_err: # pylint: disable=broad-except + self.logger.debug(f"Dynamic progress tracking failed: {progress_err}") + + # Track which managers were used for this display mode + # This is used to determine when all leagues have completed + if display_mode: + self._display_mode_to_managers.setdefault(display_mode, set()).add(manager_key) + + # Check if this manager (league) has completed all its games + # If all enabled leagues complete, the display mode cycle is complete + self._evaluate_dynamic_cycle_completion(display_mode=display_mode) + return True, actual_mode + + elif result is False: + # Manager returned False - no content available or between games + # In sequential block display, we'll try the next league if this one is complete + # The completion check happens in _display_external_mode() + self.logger.debug( + f"Manager {manager_class_name} returned False - no content or between games" + ) + return False, None + + else: + # Result is None or other unexpected value - assume success + # This handles edge cases where managers return None instead of True/False + manager_key = self._build_manager_key(actual_mode, manager) + + try: + self._record_dynamic_progress(manager, actual_mode=actual_mode, display_mode=display_mode) + except Exception as progress_err: # pylint: disable=broad-except + self.logger.debug(f"Dynamic progress tracking failed: {progress_err}") + + # Track which managers were used for this display mode + if display_mode: + self._display_mode_to_managers.setdefault(display_mode, set()).add(manager_key) + + self._evaluate_dynamic_cycle_completion(display_mode=display_mode) + return True, actual_mode + + def _display_external_mode(self, display_mode: str, force_clear: bool) -> bool: + """ + Handle display for external display_mode calls (from display controller). - # Priority 2: Favorite teams - favorite_score = 0 if self._is_favorite_game(game) else 1 + Routes granular modes (mlb_live, ncaa_baseball_recent, etc.) to _display_league_mode. - # Priority 3: Start time (earlier games first for upcoming, later for recent) - start_time = game.get('start_time', '') + Args: + display_mode: External mode name (e.g., 'mlb_live', 'mlb_recent', 'ncaa_baseball_upcoming') + force_clear: Whether to force clear display - return (live_score, favorite_score, start_time) + Returns: + True if content was displayed, False otherwise + """ + self.logger.debug(f"Display called with mode: {display_mode}") + + # Extract the mode type (live, recent, upcoming) + mode_type = self._extract_mode_type(display_mode) + if not mode_type: + self.logger.warning(f"Unknown display_mode: {display_mode}") + return False + + # Check if this is a granular mode (league-specific) + # Granular modes: mlb_live, ncaa_baseball_recent, etc. + league = None + if display_mode.startswith('mlb_'): + league = 'mlb' + elif display_mode.startswith('milb_'): + league = 'milb' + elif display_mode.startswith('ncaa_baseball_'): + league = 'ncaa_baseball' + # If no league prefix, it's a combined mode - keep league=None + + self.logger.debug( + f"Mode: {display_mode}, League: {league}, Mode type: {mode_type}, " + f"MLB enabled: {self.mlb_enabled}, MiLB enabled: {self.milb_enabled}, NCAA Baseball enabled: {self.ncaa_baseball_enabled}" + ) + + # If granular mode (league-specific), display only that league + if league: + return self._display_league_mode(league, mode_type, force_clear) + + # Combined mode - display across all enabled leagues + + # Check if we should use scroll mode for this game type + if self._should_use_scroll_mode(mode_type): + return self._display_scroll_mode(display_mode, mode_type, force_clear) + + # Otherwise, use switch mode (existing behavior) - self.current_games.sort(key=sort_key) + # Resolve managers to try for this mode type + managers_to_try = self._resolve_managers_for_mode(mode_type) - def _fetch_all_rankings(self): - """Fetch team rankings for all enabled leagues that support rankings. + # Try each manager until one returns True (has content) + for current_manager in managers_to_try: + success, _ = self._try_manager_display( + current_manager, force_clear, display_mode, mode_type, None + ) - Uses atomic swap to avoid concurrent read/write issues with display threads. + if success: + self.logger.info(f"Plugin display() returning True for {display_mode}") + return True + + # No manager had content - log why + if not managers_to_try: + self.logger.warning( + f"_display_external_mode() called with granular mode: {display_mode}. " + f"This should be handled by display() directly. " + f"(mlb_has_manager={self._get_manager_for_league_mode('mlb', mode_type) is not None}, " + f"milb_has_manager={self._get_manager_for_league_mode('milb', mode_type) is not None}, " + f"ncaa_baseball_has_manager={self._get_manager_for_league_mode('ncaa_baseball', mode_type) is not None})" + ) + # Try to handle it anyway by parsing and calling _display_league_mode + parts = display_mode.split("_", 1) + if len(parts) == 2: + league, mode_type_str = parts + if league in self._league_registry and mode_type_str == mode_type: + return self._display_league_mode(league, mode_type, force_clear) + return False + + # Legacy combined mode handling (should not be reached with new architecture) + self.logger.warning( + f"_display_external_mode() called with combined mode: {display_mode}. " + f"Combined modes are no longer supported. Use granular modes instead." + ) + return False + + def _display_scroll_mode(self, display_mode: str, mode_type: str, force_clear: bool) -> bool: + """Handle display for scroll mode. + + Args: + display_mode: External mode name (e.g., 'mlb_live' or 'ncaa_baseball_live') + mode_type: Game type ('live', 'recent', 'upcoming') + force_clear: Whether to force clear display + + Returns: + True if content was displayed, False otherwise """ - if not self._rankings_manager: - return + if not self._scroll_manager: + self.logger.warning("Scroll mode requested but scroll manager not available") + # Fall back to switch mode + return self._display_switch_mode_fallback(display_mode, mode_type, force_clear) + + # Check if we need to prepare new scroll content + scroll_key = f"{display_mode}_{mode_type}" + + if not self._scroll_prepared.get(scroll_key, False): + # Update managers first to get latest game data + if self.mlb_enabled: + league_manager = self._get_manager_for_league_mode('mlb', mode_type) + if league_manager: + self._ensure_manager_updated(league_manager) + + if self.milb_enabled: + league_manager = self._get_manager_for_league_mode('milb', mode_type) + if league_manager: + self._ensure_manager_updated(league_manager) + if self.ncaa_baseball_enabled: + ncaa_manager = self._get_manager_for_league_mode('ncaa_baseball', mode_type) + if ncaa_manager: + self._ensure_manager_updated(ncaa_manager) + + # Check if live priority should filter to only live games + live_priority_active = ( + mode_type == 'live' and + (self.mlb_live_priority or self.milb_live_priority or self.ncaa_baseball_live_priority) and + self.has_live_content() + ) + + # Collect games from all leagues using scroll mode + games, leagues = self._collect_games_for_scroll(mode_type, live_priority_active) + + if not games: + self.logger.debug(f"No games to scroll for {display_mode}") + self._scroll_prepared[scroll_key] = False + self._scroll_active[scroll_key] = False + return False + + # Get rankings cache for display + rankings = self._get_rankings_cache() + + # Prepare scroll content + success = self._scroll_manager.prepare_and_display( + games, mode_type, leagues, rankings + ) + + if success: + self._scroll_prepared[scroll_key] = True + self._scroll_active[scroll_key] = True + self.logger.info( + f"[Baseball Scroll] Started scrolling {len(games)} {mode_type} games " + f"from {', '.join(leagues)}" + ) + else: + self._scroll_prepared[scroll_key] = False + self._scroll_active[scroll_key] = False + return False + + # Display the next scroll frame + if self._scroll_active.get(scroll_key, False): + displayed = self._scroll_manager.display_frame(mode_type) + + if displayed: + # Check if scroll is complete + if self._scroll_manager.is_complete(mode_type): + self.logger.info(f"[Baseball Scroll] Cycle complete for {display_mode}") + # Reset for next cycle + self._scroll_prepared[scroll_key] = False + self._scroll_active[scroll_key] = False + # Mark cycle as complete for dynamic duration + self._dynamic_cycle_complete = True + + return True + else: + # Scroll display failed + self._scroll_active[scroll_key] = False + return False + + return False + + def _display_switch_mode_fallback(self, display_mode: str, mode_type: str, force_clear: bool) -> bool: + """Fallback to switch mode when scroll is not available. + + This is essentially the same logic as the switch mode portion of _display_external_mode. + """ + # Resolve managers to try for this mode type (in priority order) + managers_to_try = self._resolve_managers_for_mode(mode_type) + + # Try each manager until one returns True (has content) + # Sequential block display handles league rotation at a higher level + for current_manager in managers_to_try: + success, _ = self._try_manager_display( + current_manager, force_clear, display_mode, mode_type, None + ) + + if success: + return True + + return False - # ESPN league identifiers for rankings API - league_mappings = { - 'mlb': ('baseball', 'mlb'), - 'ncaa_baseball': ('baseball', 'college-baseball'), - } + def _display_league_mode(self, league: str, mode_type: str, force_clear: bool) -> bool: + """ + Display a specific league/mode combination (e.g., MLB Recent, NCAA Baseball Upcoming). + + This method displays content from a single league and mode type, used when + rotation_order specifies granular modes like 'mlb_recent' or 'ncaa_baseball_upcoming'. + + Args: + league: League ID ('mlb' or 'ncaa_baseball') + mode_type: Mode type ('live', 'recent', or 'upcoming') + force_clear: Whether to force clear display + + Returns: + True if content was displayed, False otherwise + """ + # Validate league + if league not in self._league_registry: + self.logger.warning(f"Invalid league in _display_league_mode: {league}") + return False + + # Check if league is enabled + if not self._league_registry[league].get('enabled', False): + self.logger.debug(f"League {league} is disabled, skipping") + return False + + # Get manager for this league/mode combination + manager = self._get_league_manager_for_mode(league, mode_type) + if not manager: + self.logger.debug(f"No manager available for {league} {mode_type}") + return False + + # Create display mode name for tracking + display_mode = f"{league}_{mode_type}" + + # Set display context for dynamic duration tracking + self._current_display_league = league + self._current_display_mode_type = mode_type + + # Try to display content from this league's manager + success, _ = self._try_manager_display( + manager, force_clear, display_mode, mode_type, None + ) + + # Only track mode start time and check duration if we actually have content to display + if success: + # Track mode start time for per-mode duration enforcement (only when content exists) + if display_mode not in self._mode_start_time: + self._mode_start_time[display_mode] = time.time() + self.logger.debug(f"Started tracking time for {display_mode}") + + # Check if mode-level duration has expired (only check if we have content) + effective_mode_duration = self._get_effective_mode_duration(display_mode, mode_type) + if effective_mode_duration is not None: + elapsed_time = time.time() - self._mode_start_time[display_mode] + if elapsed_time >= effective_mode_duration: + # Mode duration expired - time to rotate + self.logger.info( + f"Mode duration expired for {display_mode}: " + f"{elapsed_time:.1f}s >= {effective_mode_duration}s. " + f"Rotating to next mode (progress preserved for resume)." + ) + # Reset mode start time for next cycle + self._mode_start_time[display_mode] = time.time() + return False + + self.logger.debug( + f"Displayed content from {league} {mode_type} (mode: {display_mode})" + ) + else: + # No content - clear any existing start time so mode can start fresh when content becomes available + if display_mode in self._mode_start_time: + del self._mode_start_time[display_mode] + self.logger.debug(f"Cleared mode start time for {display_mode} (no content available)") + + self.logger.debug( + f"No content available for {league} {mode_type} (mode: {display_mode})" + ) + + return success - 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 + def _display_internal_cycling(self, force_clear: bool) -> bool: + """Handle display for internal mode cycling (when no display_mode provided). + + .. deprecated:: + This method exists for legacy/testing support. The display controller + should always provide display_mode parameter for proper timing behavior. - sport, league_id = league_mappings[league_key] - rankings = self._rankings_manager.fetch_rankings(sport, league_id, league_key) - if rankings: - new_cache.update(rankings) + Args: + force_clear: Whether to force clear display - # Atomic swap under lock so display threads see a consistent snapshot - with self._games_lock: - self._team_rankings_cache = new_cache + Returns: + True if content was displayed, False otherwise + """ + # Log deprecation warning (once per session) + if not getattr(self, '_internal_cycling_warned', False): + self.logger.warning( + "Using deprecated internal mode cycling. " + "For proper dynamic duration support, use display(display_mode=...) instead." + ) + self._internal_cycling_warned = True - def _get_rankings_cache(self) -> Dict[str, int]: - """Get the combined team rankings cache.""" - return self._team_rankings_cache + current_time = time.time() + + # Check if we should stay on live mode + should_stay_on_live = False + if self.has_live_content(): + # Get current mode name + current_mode = self.modes[self.current_mode_index] if self.modes else None + # If we're on a live mode, stay there + if current_mode and current_mode.endswith('_live'): + should_stay_on_live = True + # If we're not on a live mode but have live content, switch to it + elif not (current_mode and current_mode.endswith('_live')): + # Find the first live mode + for i, mode in enumerate(self.modes): + if mode.endswith('_live'): + self.current_mode_index = i + force_clear = True + self.last_mode_switch = current_time + self.logger.info(f"Live content detected - switching to display mode: {mode}") + break + + # Handle mode cycling only if not staying on live + # Get dynamic duration for current mode (falls back to display_duration) + current_mode_for_duration = self.modes[self.current_mode_index] if self.modes else None + cycle_duration = self.display_duration # Default fallback + if current_mode_for_duration: + dynamic_duration = self.get_cycle_duration(current_mode_for_duration) + if dynamic_duration is not None and dynamic_duration > 0: + cycle_duration = dynamic_duration + + if not should_stay_on_live and current_time - self.last_mode_switch >= cycle_duration: + self.current_mode_index = (self.current_mode_index + 1) % len(self.modes) + self.last_mode_switch = current_time + force_clear = True + + current_mode = self.modes[self.current_mode_index] + self.logger.info(f"Switching to display mode: {current_mode} (after {cycle_duration:.1f}s)") + + # Get current manager and display + current_manager = self._get_current_manager() + if not current_manager: + self.logger.warning("No manager available for current mode") + return False + + # Track which league/mode we're displaying for granular dynamic duration + current_mode = self.modes[self.current_mode_index] if self.modes else None + if current_mode: + # Extract mode type from mode name + mode_type = self._extract_mode_type(current_mode) + if mode_type: + self._set_display_context_from_manager(current_manager, mode_type) + + result = current_manager.display(force_clear) + if result is not False: + try: + # Build the actual mode name from league and mode_type for accurate tracking + current_mode = self.modes[self.current_mode_index] if self.modes else None + if current_mode: + manager_key = self._build_manager_key(current_mode, current_manager) + # Track which managers were used for internal mode cycling + # For internal cycling, the mode itself is the display_mode + self._display_mode_to_managers.setdefault(current_mode, set()).add(manager_key) + self._record_dynamic_progress( + current_manager, actual_mode=current_mode, display_mode=current_mode + ) + except Exception as progress_err: # pylint: disable=broad-except + self.logger.debug(f"Dynamic progress tracking failed: {progress_err}") + else: + # Manager returned False (no content) - ensure display is cleared + # This is a safety measure in case the manager didn't clear it + if force_clear: + try: + self.display_manager.clear() + self.display_manager.update_display() + except Exception as clear_err: + self.logger.debug(f"Error clearing display when manager returned False: {clear_err}") + + current_mode = self.modes[self.current_mode_index] if self.modes else None + self._evaluate_dynamic_cycle_completion(display_mode=current_mode) + return result - def _fetch_league_data(self, league_key: str, league_config: Dict) -> List[Dict]: - """Fetch game data for a specific league. + def display(self, display_mode: str = None, force_clear: bool = False) -> bool: + """Display baseball games for a specific granular mode. - Uses data_manager with background_data_service when available, - falls back to direct synchronous API calls otherwise. + The plugin now uses granular modes directly (mlb_recent, mlb_upcoming, mlb_live, + milb_recent, milb_upcoming, milb_live, + ncaa_baseball_recent, ncaa_baseball_upcoming, ncaa_baseball_live) registered in manifest.json. + The display controller handles rotation between these modes. + + Args: + display_mode: Granular mode name (e.g., 'mlb_recent', 'ncaa_baseball_upcoming', 'mlb_live') + Format: {league}_{mode_type} + If None, uses internal mode cycling (legacy support). + force_clear: If True, clear display before rendering """ - if self.data_manager: - return self._fetch_via_data_manager(league_key, league_config) - return self._fetch_league_data_sync(league_key, league_config) + if not self.is_enabled: + return False - def _fetch_via_data_manager(self, league_key: str, league_config: Dict) -> List[Dict]: - """Fetch game data via data_manager (supports background_data_service).""" try: - if league_key == 'milb': - milb_games = self.data_manager.fetch_milb_games(league_config) - if not milb_games: - return [] - return [self._convert_milb_game(g) for g in milb_games.values()] + # Track the current active display mode for use in is_cycle_complete() + if display_mode: + # Early exit: Skip if this mode is not in our available modes (disabled league) + if display_mode not in self.modes: + self.logger.debug(f"Skipping disabled mode: {display_mode} (not in available modes: {self.modes})") + return False + self._current_active_display_mode = display_mode + + # Route to appropriate display handler + if display_mode: + # Handle granular modes (mlb_recent, ncaa_baseball_upcoming, mlb_live, etc.) + # All modes are now league-specific granular modes + if display_mode.startswith("baseball_"): + # Legacy combined mode - extract mode_type and show all enabled leagues + mode_type_str = display_mode.replace("baseball_", "") + if mode_type_str not in ['live', 'recent', 'upcoming']: + self.logger.warning( + f"Invalid legacy combined mode: {display_mode}" + ) + return False + + # Show all enabled leagues for this mode type (sequential block) + # This maintains backward compatibility during transition + enabled_leagues = self._get_enabled_leagues_for_mode(mode_type_str) + if not enabled_leagues: + self.logger.debug( + f"No enabled leagues for legacy mode {display_mode}" + ) + return False + + # Try to display from first enabled league + # This is a simplified fallback for legacy mode support + for league_id in enabled_leagues: + success = self._display_league_mode(league_id, mode_type_str, force_clear) + if success: + return True + + # No content from any league + return False + + # Parse granular mode name: {league}_{mode_type} + # e.g., "mlb_recent" -> league="mlb", mode_type="recent" + # e.g., "ncaa_baseball_recent" -> league="ncaa_baseball", mode_type="recent" + # e.g., "uefa.champions_recent" -> league="uefa.champions", mode_type="recent" (for soccer) + # + # Scalable approach: Check league registry first, then extract mode type + # This works for any league naming convention (underscores, dots, etc.) + mode_type_str = None + league = None + + # Known mode type suffixes (standardized across all sports plugins) + mode_suffixes = ['_live', '_recent', '_upcoming'] + + # Try to match against league registry first (most reliable) + # Check each league ID in registry to see if display_mode starts with it + for league_id in self._league_registry.keys(): + for mode_suffix in mode_suffixes: + expected_mode = f"{league_id}{mode_suffix}" + if display_mode == expected_mode: + league = league_id + mode_type_str = mode_suffix[1:] # Remove leading underscore + break + if league: + break + + # Fallback: If no registry match, parse from the end (for backward compatibility) + if not league: + for mode_suffix in mode_suffixes: + if display_mode.endswith(mode_suffix): + mode_type_str = mode_suffix[1:] # Remove leading underscore + league = display_mode[:-len(mode_suffix)] # Everything before the suffix + # Validate it's a known league + if league in self._league_registry: + break + else: + # Not a known league, try next suffix + league = None + mode_type_str = None + + if not mode_type_str or not league: + self.logger.warning( + f"Invalid granular display_mode format: {display_mode} " + f"(expected format: {{league}}_{{mode_type}}, e.g., 'mlb_recent' or 'ncaa_baseball_recent'). " + f"Valid leagues: {list(self._league_registry.keys())}" + ) + return False + + # Validate league exists in registry (double-check) + if league not in self._league_registry: + self.logger.warning( + f"Invalid league in display_mode: {league} (mode: {display_mode}). " + f"Valid leagues: {list(self._league_registry.keys())}" + ) + return False + + # Check if league is enabled + if not self._league_registry[league].get('enabled', False): + self.logger.debug( + f"League {league} is disabled, skipping {display_mode}" + ) + return False + + # Check if mode is enabled for this league + league_config = self.config.get(league, {}) + display_modes_config = league_config.get("display_modes", {}) + + mode_enabled = True + if mode_type_str == 'live': + mode_enabled = display_modes_config.get("show_live", True) + elif mode_type_str == 'recent': + mode_enabled = display_modes_config.get("show_recent", True) + elif mode_type_str == 'upcoming': + mode_enabled = display_modes_config.get("show_upcoming", True) + + if not mode_enabled: + self.logger.debug( + f"Mode {mode_type_str} is disabled for league {league}, skipping {display_mode}" + ) + return False + + # Display this specific league/mode combination + return self._display_league_mode(league, mode_type_str, force_clear) else: - result = self.data_manager.fetch_season_data(league_key, league_config) - if result and 'events' in result: - return self._process_api_response(result, league_key, league_config) - return [] - except Exception as e: - self.logger.exception(f"Error fetching {league_key} via data manager, falling back to sync") - 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 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', - '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': '', - '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]), - } + # No display_mode provided - use internal cycling (legacy support) + return self._display_internal_cycling(force_clear) - def _fetch_league_data_sync(self, league_key: str, league_config: Dict) -> List[Dict]: - """Synchronous fallback for fetching game data when data_manager is unavailable.""" - cache_key = f"baseball_{league_key}_{datetime.now().strftime('%Y%m%d')}" - try: - update_interval = int(league_config.get('update_interval_seconds', 60)) - except (ValueError, TypeError): - update_interval = 60 + except Exception as e: + self.logger.error(f"Error in display method: {e}") + return False - # Check cache first (use league-specific interval) - cached_data = self.cache_manager.get(cache_key) - if cached_data and (time.time() - self.last_update) < update_interval: - self.logger.debug(f"Using cached data for {league_key}") - return cached_data + def has_live_priority(self) -> bool: + if not self.is_enabled: + return False + result = ( + (self.mlb_enabled and self.mlb_live_priority) + or (self.milb_enabled and self.milb_live_priority) + or (self.ncaa_baseball_enabled and self.ncaa_baseball_live_priority) + ) + # Log at DEBUG level since this is called frequently and the result rarely changes + self.logger.debug(f"has_live_priority() called: mlb_enabled={self.mlb_enabled}, mlb_live_priority={self.mlb_live_priority}, milb_enabled={self.milb_enabled}, milb_live_priority={self.milb_live_priority}, ncaa_baseball_enabled={self.ncaa_baseball_enabled}, ncaa_baseball_live_priority={self.ncaa_baseball_live_priority}, result={result}") + return result - # Fetch from API - try: - url = self.ESPN_API_URLS.get(league_key) - if not url: - self.logger.error(f"Unknown league key: {league_key}") - return [] + def has_live_content(self) -> bool: + if not self.is_enabled: + self.logger.debug("[LIVE_PRIORITY_DEBUG] has_live_content: plugin not enabled, returning False") + return False - self.logger.info(f"Fetching {league_key} data from ESPN API (sync)...") - response = requests.get(url, timeout=league_config.get('background_service', {}).get('request_timeout', 30)) - response.raise_for_status() + # Check MLB live content + mlb_live = False + if ( + self.mlb_enabled + and self.mlb_live_priority + and hasattr(self, "mlb_live") + ): + raw_live_games = getattr(self.mlb_live, "live_games", []) + self.logger.debug(f"[LIVE_PRIORITY_DEBUG] MLB raw live_games count: {len(raw_live_games)}") + + # Log each raw game for debugging + for i, game in enumerate(raw_live_games): + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] MLB raw game {i+1}: " + f"{game.get('away_abbr')}@{game.get('home_abbr')} " + f"is_final={game.get('is_final')}, is_live={game.get('is_live')}, " + f"clock={game.get('clock')}, period={game.get('period')}, " + f"period_text={game.get('period_text')}" + ) - data = response.json() - games = self._process_api_response(data, league_key, league_config) + if raw_live_games: + # Filter out any games that are final or appear over + live_games = [g for g in raw_live_games if not g.get("is_final", False)] + games_after_final_filter = len(live_games) + self.logger.debug(f"[LIVE_PRIORITY_DEBUG] MLB after is_final filter: {games_after_final_filter} games") + + # Additional validation using helper method if available + if hasattr(self.mlb_live, "_is_game_really_over"): + games_before_really_over = len(live_games) + for game in live_games[:]: # Iterate over copy + is_really_over = self.mlb_live._is_game_really_over(game) + if is_really_over: + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] MLB _is_game_really_over=True for " + f"{game.get('away_abbr')}@{game.get('home_abbr')} " + f"(clock={game.get('clock')}, period={game.get('period')}, " + f"period_text={game.get('period_text')})" + ) + live_games.remove(game) + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] MLB after _is_game_really_over filter: " + f"{len(live_games)} games (removed {games_before_really_over - len(live_games)})" + ) - # Cache for league-specific interval - self.cache_manager.set(cache_key, games, ttl=update_interval * 2) + if live_games: + # If favorite teams are configured, only return True if there are live games for favorite teams + favorite_teams = getattr(self.mlb_live, "favorite_teams", []) + self.logger.debug(f"[LIVE_PRIORITY_DEBUG] MLB favorite_teams configured: {favorite_teams}") + + if favorite_teams: + # Check if any live game involves a favorite team + for game in live_games: + home = game.get("home_abbr") + away = game.get("away_abbr") + home_match = home in favorite_teams + away_match = away in favorite_teams + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] MLB favorite check: {away}@{home} - " + f"home_in_favorites={home_match}, away_in_favorites={away_match}" + ) + + mlb_live = any( + game.get("home_abbr") in favorite_teams + or game.get("away_abbr") in favorite_teams + for game in live_games + ) + self.logger.debug(f"[LIVE_PRIORITY_DEBUG] MLB favorite team match result: {mlb_live}") + else: + # No favorite teams configured, return True if any live games exist + mlb_live = True + self.logger.debug("[LIVE_PRIORITY_DEBUG] MLB no favorites configured, mlb_live=True") - return games + self.logger.info(f"has_live_content: MLB live_games={len(live_games)}, filtered_live_games={len(live_games)}, mlb_live={mlb_live}") + else: + self.logger.debug("[LIVE_PRIORITY_DEBUG] MLB no live games after filtering") + else: + self.logger.debug("[LIVE_PRIORITY_DEBUG] MLB raw live_games is empty") + else: + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] MLB check skipped: mlb_enabled={self.mlb_enabled}, " + f"mlb_live_priority={self.mlb_live_priority}, has_mlb_live={hasattr(self, 'mlb_live')}" + ) - except requests.RequestException as e: - self.logger.error(f"Error fetching {league_key} data: {e}") - return [] - except Exception as e: - self.logger.error(f"Error processing {league_key} data: {e}") - return [] + # Check MiLB live content + milb_live = False + if ( + self.milb_enabled + and self.milb_live_priority + and hasattr(self, "milb_live") + ): + raw_live_games = getattr(self.milb_live, "live_games", []) + self.logger.debug(f"[LIVE_PRIORITY_DEBUG] MiLB raw live_games count: {len(raw_live_games)}") + + # Log each raw game for debugging + for i, game in enumerate(raw_live_games): + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] MiLB raw game {i+1}: " + f"{game.get('away_abbr')}@{game.get('home_abbr')} " + f"is_final={game.get('is_final')}, is_live={game.get('is_live')}, " + f"clock={game.get('clock')}, period={game.get('period')}, " + f"period_text={game.get('period_text')}" + ) - def _process_api_response(self, data: Dict, league_key: str, league_config: Dict) -> List[Dict]: - """Process ESPN API response into standardized game format.""" - games = [] + if raw_live_games: + # Filter out any games that are final or appear over + live_games = [g for g in raw_live_games if not g.get("is_final", False)] + games_after_final_filter = len(live_games) + self.logger.debug(f"[LIVE_PRIORITY_DEBUG] MiLB after is_final filter: {games_after_final_filter} games") + + # Additional validation using helper method if available + if hasattr(self.milb_live, "_is_game_really_over"): + games_before_really_over = len(live_games) + for game in live_games[:]: # Iterate over copy + is_really_over = self.milb_live._is_game_really_over(game) + if is_really_over: + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] MiLB _is_game_really_over=True for " + f"{game.get('away_abbr')}@{game.get('home_abbr')} " + f"(clock={game.get('clock')}, period={game.get('period')}, " + f"period_text={game.get('period_text')})" + ) + live_games.remove(game) + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] MiLB after _is_game_really_over filter: " + f"{len(live_games)} games (removed {games_before_really_over - len(live_games)})" + ) - try: - events = data.get('events', []) + if live_games: + # If favorite teams are configured, only return True if there are live games for favorite teams + favorite_teams = getattr(self.milb_live, "favorite_teams", []) + self.logger.debug(f"[LIVE_PRIORITY_DEBUG] MiLB favorite_teams configured: {favorite_teams}") + + if favorite_teams: + # Check if any live game involves a favorite team + for game in live_games: + home = game.get("home_abbr") + away = game.get("away_abbr") + home_match = home in favorite_teams + away_match = away in favorite_teams + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] MiLB favorite check: {away}@{home} - " + f"home_in_favorites={home_match}, away_in_favorites={away_match}" + ) + + milb_live = any( + game.get("home_abbr") in favorite_teams + or game.get("away_abbr") in favorite_teams + for game in live_games + ) + self.logger.debug(f"[LIVE_PRIORITY_DEBUG] MiLB favorite team match result: {milb_live}") + else: + # No favorite teams configured, return True if any live games exist + milb_live = True + self.logger.debug("[LIVE_PRIORITY_DEBUG] MiLB no favorites configured, milb_live=True") - for event in events: - try: - game = self._extract_game_info(event, league_key, league_config) - if game: - games.append(game) - except Exception as e: - self.logger.error(f"Error extracting game info: {e}") - continue + self.logger.info(f"has_live_content: MiLB live_games={len(live_games)}, filtered_live_games={len(live_games)}, milb_live={milb_live}") + else: + self.logger.debug("[LIVE_PRIORITY_DEBUG] MiLB no live games after filtering") + else: + self.logger.debug("[LIVE_PRIORITY_DEBUG] MiLB raw live_games is empty") + else: + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] MiLB check skipped: milb_enabled={self.milb_enabled}, " + f"milb_live_priority={self.milb_live_priority}, has_milb_live={hasattr(self, 'milb_live')}" + ) - except Exception as e: - self.logger.error(f"Error processing API response: {e}") + # Check NCAA Baseball live content + ncaa_live = False + if ( + self.ncaa_baseball_enabled + and self.ncaa_baseball_live_priority + and hasattr(self, "ncaa_baseball_live") + ): + raw_live_games = getattr(self.ncaa_baseball_live, "live_games", []) + self.logger.debug(f"[LIVE_PRIORITY_DEBUG] NCAA Baseball raw live_games count: {len(raw_live_games)}") + + # Log each raw game for debugging + for i, game in enumerate(raw_live_games): + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] NCAA Baseball raw game {i+1}: " + f"{game.get('away_abbr')}@{game.get('home_abbr')} " + f"is_final={game.get('is_final')}, is_live={game.get('is_live')}, " + f"clock={game.get('clock')}, period={game.get('period')}, " + f"period_text={game.get('period_text')}" + ) - return games + if raw_live_games: + # Filter out any games that are final or appear over + live_games = [g for g in raw_live_games if not g.get("is_final", False)] + games_after_final_filter = len(live_games) + self.logger.debug(f"[LIVE_PRIORITY_DEBUG] NCAA Baseball after is_final filter: {games_after_final_filter} games") + + # Additional validation using helper method if available + if hasattr(self.ncaa_baseball_live, "_is_game_really_over"): + games_before_really_over = len(live_games) + for game in live_games[:]: # Iterate over copy + is_really_over = self.ncaa_baseball_live._is_game_really_over(game) + if is_really_over: + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] NCAA Baseball _is_game_really_over=True for " + f"{game.get('away_abbr')}@{game.get('home_abbr')} " + f"(clock={game.get('clock')}, period={game.get('period')}, " + f"period_text={game.get('period_text')})" + ) + live_games.remove(game) + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] NCAA Baseball after _is_game_really_over filter: " + f"{len(live_games)} games (removed {games_before_really_over - len(live_games)})" + ) - def _extract_game_info(self, event: Dict, league_key: str, league_config: Dict) -> Optional[Dict]: - """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 live_games: + # If favorite teams are configured, only return True if there are live games for favorite teams + favorite_teams = getattr(self.ncaa_baseball_live, "favorite_teams", []) + self.logger.debug(f"[LIVE_PRIORITY_DEBUG] NCAA Baseball favorite_teams configured: {favorite_teams}") + + if favorite_teams: + # Check if any live game involves a favorite team + for game in live_games: + home = game.get("home_abbr") + away = game.get("away_abbr") + home_match = home in favorite_teams + away_match = away in favorite_teams + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] NCAA Baseball favorite check: {away}@{home} - " + f"home_in_favorites={home_match}, away_in_favorites={away_match}" + ) + + ncaa_live = any( + game.get("home_abbr") in favorite_teams + or game.get("away_abbr") in favorite_teams + for game in live_games + ) + self.logger.debug(f"[LIVE_PRIORITY_DEBUG] NCAA Baseball favorite team match result: {ncaa_live}") + else: + # No favorite teams configured, return True if any live games exist + ncaa_live = True + self.logger.debug("[LIVE_PRIORITY_DEBUG] NCAA Baseball no favorites configured, ncaa_live=True") - if len(competitors) < 2: - return None + self.logger.info(f"has_live_content: NCAA Baseball live_games={len(live_games)}, filtered_live_games={len(live_games)}, ncaa_live={ncaa_live}") + else: + self.logger.debug("[LIVE_PRIORITY_DEBUG] NCAA Baseball no live games after filtering") + else: + self.logger.debug("[LIVE_PRIORITY_DEBUG] NCAA Baseball raw live_games is empty") + else: + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] NCAA Baseball check skipped: ncaa_baseball_enabled={self.ncaa_baseball_enabled}, " + f"ncaa_baseball_live_priority={self.ncaa_baseball_live_priority}, has_ncaa_baseball_live={hasattr(self, 'ncaa_baseball_live')}" + ) - # Find home and away teams - home_team = next((c for c in competitors if c.get('homeAway') == 'home'), None) - away_team = next((c for c in competitors if c.get('homeAway') == 'away'), None) + result = mlb_live or milb_live or ncaa_live - if not home_team or not away_team: - return None + # Throttle logging when returning False to reduce log noise + # Always log True immediately (important), but only log False every 60 seconds + current_time = time.time() + should_log = result or (current_time - self._last_live_content_false_log >= self._live_content_log_interval) - 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') + if should_log: + if result: + # Always log True results immediately + self.logger.info(f"has_live_content() returning {result}: mlb_live={mlb_live}, milb_live={milb_live}, ncaa_live={ncaa_live}") else: - logo_dir = 'assets/sports/mlb_logos' - - game = { - 'league': league_key, - 'league_config': league_config, - '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', ''), - } + # Log False results only every 60 seconds + self.logger.info(f"has_live_content() returning {result}: mlb_live={mlb_live}, milb_live={milb_live}, ncaa_live={ncaa_live}") + self._last_live_content_false_log = current_time + + return result - # 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 + def get_live_modes(self) -> list: + """ + Return the registered plugin mode name(s) that have live content. + + Returns granular live modes (mlb_live, ncaa_baseball_live) that actually have live content. + The plugin is now registered with granular modes in manifest.json. + """ + if not self.is_enabled: + return [] - except Exception as e: - self.logger.error(f"Error extracting game info: {e}") - return None + live_modes = [] + + # Check MLB live content + if ( + self.mlb_enabled + and self.mlb_live_priority + and hasattr(self, "mlb_live") + ): + live_games = getattr(self.mlb_live, "live_games", []) + if live_games: + # Filter out any games that are final or appear over + live_games = [g for g in live_games if not g.get("is_final", False)] + # Additional validation using helper method if available + if hasattr(self.mlb_live, "_is_game_really_over"): + live_games = [g for g in live_games if not self.mlb_live._is_game_really_over(g)] + + if live_games: + # If favorite teams are configured, only return if there are live games for favorite teams + favorite_teams = getattr(self.mlb_live, "favorite_teams", []) + if favorite_teams: + if any( + game.get("home_abbr") in favorite_teams + or game.get("away_abbr") in favorite_teams + for game in live_games + ): + live_modes.append("mlb_live") + else: + # No favorite teams configured, include if any live games exist + live_modes.append("mlb_live") + + # Check MiLB live content + if ( + self.milb_enabled + and self.milb_live_priority + and hasattr(self, "milb_live") + ): + live_games = getattr(self.milb_live, "live_games", []) + if live_games: + live_games = [g for g in live_games if not g.get("is_final", False)] + if hasattr(self.milb_live, "_is_game_really_over"): + live_games = [g for g in live_games if not self.milb_live._is_game_really_over(g)] + + if live_games: + favorite_teams = getattr(self.milb_live, "favorite_teams", []) + if favorite_teams: + if any( + game.get("home_abbr") in favorite_teams + or game.get("away_abbr") in favorite_teams + for game in live_games + ): + live_modes.append("milb_live") + else: + live_modes.append("milb_live") + + # Check NCAA Baseball live content + if ( + self.ncaa_baseball_enabled + and self.ncaa_baseball_live_priority + and hasattr(self, "ncaa_baseball_live") + ): + live_games = getattr(self.ncaa_baseball_live, "live_games", []) + if live_games: + live_games = [g for g in live_games if not g.get("is_final", False)] + if hasattr(self.ncaa_baseball_live, "_is_game_really_over"): + live_games = [g for g in live_games if not self.ncaa_baseball_live._is_game_really_over(g)] + + if live_games: + favorite_teams = getattr(self.ncaa_baseball_live, "favorite_teams", []) + if favorite_teams: + if any( + game.get("home_abbr") in favorite_teams + or game.get("away_abbr") in favorite_teams + for game in live_games + ): + live_modes.append("ncaa_baseball_live") + else: + live_modes.append("ncaa_baseball_live") - def _is_favorite_game(self, game: Dict) -> bool: - """Check if game involves a favorite team.""" - league_config = game.get('league_config', {}) - favorites = league_config.get('favorite_teams', []) + return live_modes - if not favorites: - return False + def _get_game_duration(self, league: str, mode_type: str, manager=None) -> float: + """Get game duration for a league and mode type combination. + + Resolves duration using the following hierarchy: + 1. Manager's game_display_duration attribute (if manager provided) + 2. League-specific mode duration (e.g., mlb.live_game_duration) + 3. League-specific default (15 seconds) + + Args: + league: League name ('mlb' or 'ncaa_baseball') + mode_type: Mode type ('live', 'recent', or 'upcoming') + manager: Optional manager instance (if provided, checks manager's game_display_duration) + + Returns: + Game duration in seconds (float) + """ + # First, try manager's game_display_duration if available + if manager: + manager_duration = getattr(manager, 'game_display_duration', None) + if manager_duration is not None: + return float(manager_duration) + + # Next, try league-specific mode duration + league_config = self.config.get(league, {}) + mode_duration_key = f"{mode_type}_game_duration" # e.g., 'live_game_duration' + mode_duration = league_config.get(mode_duration_key) + if mode_duration is not None: + return float(mode_duration) + + # Fallback to league-specific default (15 seconds) + return 15.0 - return game.get('home_abbr', '') in favorites or game.get('away_abbr', '') in favorites + def _get_mode_duration(self, mode_type: str, league: Optional[str] = None) -> Optional[float]: + """Get mode-level duration for a specific mode type, optionally for a specific league. + + Resolves mode-level duration using the following hierarchy: + 1. Per-league mode duration override (if league specified, only check that league) + 2. Per-league mode duration overrides (if all enabled leagues have same value, or max if different) + 3. None (triggers dynamic calculation based on game count) + + Args: + mode_type: Mode type ('live', 'recent', or 'upcoming') + league: Optional league ID ('mlb' or 'ncaa_baseball'). If provided, only checks that league's duration. + + Returns: + Mode duration in seconds (float) or None if not configured + + Examples: + - _get_mode_duration('recent', 'mlb') → Returns MLB's recent_mode_duration if set + - _get_mode_duration('recent') → Returns max of all enabled leagues or top-level + - If recent_mode_duration=60, returns 60.0 + - If MLB has recent_mode_duration=45 and NCAA Baseball has 60, returns 60.0 (max) + - If neither configured, returns None (use dynamic calculation) + """ + # If specific league requested, only check that league + if league: + if league not in self._league_registry: + self.logger.warning(f"Invalid league in _get_mode_duration: {league}") + return None + + # Check per-league override first + league_config = self.config.get(league, {}) + league_mode_durations = league_config.get('mode_durations', {}) + mode_duration_key = f"{mode_type}_mode_duration" # e.g., 'recent_mode_duration' + league_duration = league_mode_durations.get(mode_duration_key) + if league_duration is not None: + self.logger.debug( + f"_get_mode_duration({mode_type}, {league}): using per-league duration={league_duration}s" + ) + return float(league_duration) + + # No mode duration configured for this league + self.logger.debug( + f"_get_mode_duration({mode_type}, {league}): no mode duration configured, will use dynamic calculation" + ) + return None + + # No specific league - check all enabled leagues (existing logic) + # Check for per-league overrides + league_durations = [] + + # Check MLB if enabled + if self.mlb_enabled: + mlb_config = self.config.get('mlb', {}) + mlb_mode_durations = mlb_config.get('mode_durations', {}) + mode_duration_key = f"{mode_type}_mode_duration" # e.g., 'recent_mode_duration' + mlb_duration = mlb_mode_durations.get(mode_duration_key) + if mlb_duration is not None: + league_durations.append(float(mlb_duration)) + + # Check MiLB if enabled + if self.milb_enabled: + milb_config = self.config.get('milb', {}) + milb_mode_durations = milb_config.get('mode_durations', {}) + mode_duration_key = f"{mode_type}_mode_duration" # e.g., 'recent_mode_duration' + milb_duration = milb_mode_durations.get(mode_duration_key) + if milb_duration is not None: + league_durations.append(float(milb_duration)) + + # Check NCAA Baseball if enabled + if self.ncaa_baseball_enabled: + ncaa_baseball_config = self.config.get('ncaa_baseball', {}) + ncaa_mode_durations = ncaa_baseball_config.get('mode_durations', {}) + mode_duration_key = f"{mode_type}_mode_duration" # e.g., 'recent_mode_duration' + ncaa_duration = ncaa_mode_durations.get(mode_duration_key) + if ncaa_duration is not None: + league_durations.append(float(ncaa_duration)) + + # If we have per-league durations, use the maximum to ensure all leagues get their time + if league_durations: + max_duration = max(league_durations) + self.logger.debug( + f"_get_mode_duration({mode_type}): per-league durations={league_durations}, using max={max_duration}s" + ) + return max_duration + + # No mode duration configured - return None to trigger dynamic calculation + self.logger.debug( + f"_get_mode_duration({mode_type}): no mode duration configured, will use dynamic calculation" + ) + return None - def display(self, display_mode: str = None, force_clear: bool = False) -> bool: + def _get_effective_mode_duration(self, display_mode: str, mode_type: str) -> Optional[float]: + """Get effective mode duration integrating with dynamic duration caps. + + This method combines mode-level durations with dynamic duration caps to determine + the actual duration the display controller should use for a mode. + + Supports granular modes (mlb_recent, ncaa_baseball_upcoming, etc.). + + Resolution logic: + 1. Parse display_mode to extract league from granular mode + 2. Get base mode duration from _get_mode_duration() (with league) + 3. Check if dynamic duration is enabled for this mode + 4. If both mode duration and dynamic cap are set, use minimum + 5. If only one is set, use that value + 6. If neither is set, return None (triggers dynamic calculation) + + Args: + display_mode: External display mode name (e.g., 'mlb_recent', 'ncaa_baseball_upcoming', 'mlb_live') + mode_type: Mode type ('live', 'recent', or 'upcoming') + + Returns: + Effective mode duration in seconds (float) or None if not configured + + Examples: + - mode_duration=60s, dynamic_cap=45s → returns 45.0 + - mode_duration=60s, no dynamic cap → returns 60.0 + - no mode_duration, dynamic_cap=45s → returns None (use dynamic calculation with cap) + - neither set → returns None (use dynamic calculation) """ - Display baseball games. + # Parse display_mode to extract league if it's a granular mode + league = None + if "_" in display_mode and not display_mode.startswith("baseball_"): + # Use startswith checks to correctly handle multi-underscore league IDs + if display_mode.startswith("ncaa_baseball_"): + league = "ncaa_baseball" + elif display_mode.startswith("milb_"): + league = "milb" + elif display_mode.startswith("mlb_"): + league = "mlb" + + # Get base mode duration (with league if granular mode) + mode_duration = self._get_mode_duration(mode_type, league=league) + + # Check if dynamic duration is enabled and get cap + # We need to temporarily set display context to check dynamic settings + # Save current context + saved_league = self._current_display_league + saved_mode_type = self._current_display_mode_type + + # Set context for enabled leagues (check all enabled leagues for dynamic caps) + dynamic_caps = [] + + # If specific league requested (granular mode), only check that league + if league: + self._current_display_league = league + self._current_display_mode_type = mode_type + if self.supports_dynamic_duration(): + dynamic_cap = self.get_dynamic_duration_cap() + if dynamic_cap is not None: + dynamic_caps.append(dynamic_cap) + else: + # No specific league - check all enabled leagues (combined mode) + # Check MLB dynamic cap if enabled + if self.mlb_enabled: + self._current_display_league = 'mlb' + self._current_display_mode_type = mode_type + if self.supports_dynamic_duration(): + dynamic_cap = self.get_dynamic_duration_cap() + if dynamic_cap is not None: + dynamic_caps.append(dynamic_cap) + + # Check NCAA Baseball dynamic cap if enabled + + if self.milb_enabled: + self._current_display_league = 'milb' + self._current_display_mode_type = mode_type + if self.supports_dynamic_duration(): + dynamic_cap = self.get_dynamic_duration_cap() + if dynamic_cap is not None: + dynamic_caps.append(dynamic_cap) + + # Check NCAA Baseball dynamic cap if enabled + if self.ncaa_baseball_enabled: + self._current_display_league = 'ncaa_baseball' + self._current_display_mode_type = mode_type + if self.supports_dynamic_duration(): + dynamic_cap = self.get_dynamic_duration_cap() + if dynamic_cap is not None: + dynamic_caps.append(dynamic_cap) + + # Restore context + self._current_display_league = saved_league + self._current_display_mode_type = saved_mode_type + + # If we have dynamic caps, use the maximum (most permissive) + effective_dynamic_cap = max(dynamic_caps) if dynamic_caps else None + + # Apply integration logic + if mode_duration is not None and effective_dynamic_cap is not None: + # Both set - use minimum + effective_duration = min(mode_duration, effective_dynamic_cap) + self.logger.debug( + f"_get_effective_mode_duration({display_mode}, {mode_type}): " + f"mode_duration={mode_duration}s, dynamic_cap={effective_dynamic_cap}s, " + f"using min={effective_duration}s" + ) + return effective_duration + elif mode_duration is not None: + # Only mode duration set + self.logger.debug( + f"_get_effective_mode_duration({display_mode}, {mode_type}): " + f"using mode_duration={mode_duration}s (no dynamic cap)" + ) + return mode_duration + else: + # Mode duration not set (dynamic cap might be set, but we return None + # to trigger dynamic calculation which will apply the cap) + self.logger.debug( + f"_get_effective_mode_duration({display_mode}, {mode_type}): " + f"no mode_duration (dynamic_cap={effective_dynamic_cap}), will use dynamic calculation" + ) + return None + def get_cycle_duration(self, display_mode: str = None) -> Optional[float]: + """ + Calculate the expected cycle duration for a display mode based on the number of games. + + This implements dynamic duration scaling with support for mode-level durations: + - Mode-level duration: Fixed total time for mode (recent_mode_duration, upcoming_mode_duration, live_mode_duration) + - Dynamic calculation: Total duration = num_games x per_game_duration + - For scroll mode: Duration is calculated by ScrollHelper based on content width + + Priority order: + 1. Mode-level duration (if configured) + 2. Dynamic calculation (if no mode-level duration) + 3. Dynamic duration cap applies to both if enabled + Args: - display_mode: Which mode to display (baseball_live, baseball_recent, baseball_upcoming) - force_clear: If True, clear display before rendering + display_mode: The display mode to calculate duration for (e.g., 'mlb_live', 'mlb_recent', 'ncaa_baseball_upcoming') Returns: - True if content was displayed, False if no games available + Total expected duration in seconds, or None if not applicable """ - if not self.initialized: - self._display_error("Baseball plugin not initialized") - return False - - # Determine which display mode to use - prioritize live games if enabled - if not display_mode: - # Auto-select mode based on available games and priorities - if self._has_live_games(): - display_mode = 'baseball_live' + self.logger.info(f"get_cycle_duration() called with display_mode={display_mode}, is_enabled={self.is_enabled}") + if not self.is_enabled or not display_mode: + self.logger.info(f"get_cycle_duration() returning None: is_enabled={self.is_enabled}, display_mode={display_mode}") + return None + + # Extract mode type and league (if granular mode) + mode_type = self._extract_mode_type(display_mode) + if not mode_type: + return None + + # Parse granular mode name if applicable (e.g., "mlb_recent", "ncaa_baseball_upcoming") + league = None + if "_" in display_mode and not display_mode.startswith("baseball_"): + # Use startswith checks to correctly handle multi-underscore league IDs + if display_mode.startswith("ncaa_baseball_"): + league = "ncaa_baseball" + elif display_mode.startswith("milb_"): + league = "milb" + elif display_mode.startswith("mlb_"): + league = "mlb" + + # Check if scroll mode is active for this mode type + if self._should_use_scroll_mode(mode_type) and self._scroll_manager: + # Get dynamic duration from scroll manager + scroll_duration = self._scroll_manager.get_dynamic_duration(mode_type) + if scroll_duration > 0: + self.logger.info(f"get_cycle_duration: scroll mode duration for {display_mode} = {scroll_duration}s") + return float(scroll_duration) + + # Check for mode-level duration first (priority 1) + effective_mode_duration = self._get_effective_mode_duration(display_mode, mode_type) + if effective_mode_duration is not None: + self.logger.info( + f"get_cycle_duration: using mode-level duration for {display_mode} = {effective_mode_duration}s" + ) + return effective_mode_duration + + # Fall through to dynamic calculation based on game count (priority 2) + + try: + self.logger.info(f"get_cycle_duration: extracted mode_type={mode_type}, league={league} from display_mode={display_mode}") + + total_games = 0 + per_game_duration = self.game_display_duration # Default fallback (will be overridden per league) + + # Collect managers for this mode and count their games + managers_to_check = [] + + # If granular mode (specific league), only check that league + if league: + manager = self._get_manager_for_league_mode(league, mode_type) + if manager: + managers_to_check.append((league, manager)) else: - # Fall back to recent or upcoming - display_mode = 'baseball_recent' if self._has_recent_games() else 'baseball_upcoming' + # Combined mode - check all enabled leagues + if mode_type == 'live': + if self.mlb_enabled: + league_manager = self._get_manager_for_league_mode('mlb', 'live') + if league_manager: + managers_to_check.append(('mlb', league_manager)) + + if self.milb_enabled: + league_manager = self._get_manager_for_league_mode('milb', 'live') + if league_manager: + managers_to_check.append(('milb', league_manager)) + if self.ncaa_baseball_enabled: + league_manager = self._get_manager_for_league_mode('ncaa_baseball', 'live') + if league_manager: + managers_to_check.append(('ncaa_baseball', league_manager)) + elif mode_type == 'recent': + if self.mlb_enabled: + league_manager = self._get_manager_for_league_mode('mlb', 'recent') + if league_manager: + managers_to_check.append(('mlb', league_manager)) + + if self.milb_enabled: + league_manager = self._get_manager_for_league_mode('milb', 'recent') + if league_manager: + managers_to_check.append(('milb', league_manager)) + if self.ncaa_baseball_enabled: + league_manager = self._get_manager_for_league_mode('ncaa_baseball', 'recent') + if league_manager: + managers_to_check.append(('ncaa_baseball', league_manager)) + elif mode_type == 'upcoming': + if self.mlb_enabled: + league_manager = self._get_manager_for_league_mode('mlb', 'upcoming') + if league_manager: + managers_to_check.append(('mlb', league_manager)) + + if self.milb_enabled: + league_manager = self._get_manager_for_league_mode('milb', 'upcoming') + if league_manager: + managers_to_check.append(('milb', league_manager)) + if self.ncaa_baseball_enabled: + league_manager = self._get_manager_for_league_mode('ncaa_baseball', 'upcoming') + if league_manager: + managers_to_check.append(('ncaa_baseball', league_manager)) + + # CRITICAL: Update managers BEFORE checking game counts! + self.logger.info(f"get_cycle_duration: updating {len(managers_to_check)} manager(s) before counting games") + for league_name, manager in managers_to_check: + if manager: + self._ensure_manager_updated(manager) + + # Count games from all applicable managers and calculate weighted duration + # Fix: Accumulate duration per-league instead of using last league's duration + total_duration = 0.0 + duration_breakdown = [] # For logging + + for league_name, manager in managers_to_check: + if not manager: + continue - self.current_display_mode = display_mode + # Get the appropriate game list based on mode type + if mode_type == 'live': + games = getattr(manager, 'live_games', []) + elif mode_type == 'recent': + games = getattr(manager, 'recent_games', []) + elif mode_type == 'upcoming': + games = getattr(manager, 'upcoming_games', []) + else: + games = [] - # Filter games by display mode - filtered_games = self._filter_games_by_mode(display_mode) + # Get duration for this league/mode combination + per_game_duration = self._get_game_duration( + league_name, mode_type, manager + ) - if not filtered_games: - self._display_no_games(display_mode) - return False + # Filter out invalid games + if games: + # For live games, filter out final games + if mode_type == 'live': + games = [g for g in games if not g.get('is_final', False)] + if hasattr(manager, '_is_game_really_over'): + games = [ + g for g in games + if not manager._is_game_really_over(g) + ] + + game_count = len(games) + total_games += game_count + + # Calculate this league's contribution to total duration + league_duration = game_count * per_game_duration + total_duration += league_duration + + duration_breakdown.append( + f"{league_name}: {game_count} x {per_game_duration}s = {league_duration}s" + ) - # Display the first game (rotation handled by LEDMatrix) - try: - game = filtered_games[0] - self._display_game(game, display_mode) - return True - except Exception as e: - self.logger.error(f"Error displaying game: {e}", exc_info=True) - return False + self.logger.debug( + f"get_cycle_duration: {league_name} {mode_type} has " + f"{game_count} games, per_game_duration={per_game_duration}s" + ) - def _filter_games_by_mode(self, mode: str) -> List[Dict]: - """Filter games based on display mode and per-league settings.""" - filtered = [] + self.logger.info( + f"get_cycle_duration: found {total_games} total games for {display_mode}" + ) - # Make a copy of games list under lock for thread safety - with self._games_lock: - games_copy = list(self.current_games) + if total_games == 0: + # If no games found yet, return a default duration based on config + # Use configured game_display_duration with assumed 3 games per cycle + default_games_per_cycle = 3 + default_duration = default_games_per_cycle * self.game_display_duration + self.logger.info( + f"get_cycle_duration: {display_mode} has no games yet, " + f"returning default {default_duration}s ({default_games_per_cycle} x {self.game_display_duration}s)" + ) + return default_duration - for game in games_copy: - league_key = game.get('league') - league_config = game.get('league_config', {}) - status_state = game.get('status_state', '') + # Apply min/max duration constraints if configured + min_duration = self._get_duration_floor_for_mode(mode_type) + max_duration = self._get_duration_cap_for_mode(mode_type) - # Check if this mode is enabled for this league - display_modes = league_config.get('display_modes', {}) - mode_enabled = display_modes.get(mode.replace('baseball_', ''), False) - if not mode_enabled: - continue + original_duration = total_duration - show_favorites_only = league_config.get('show_favorite_teams_only', False) - show_all_live = league_config.get('show_all_live', False) - if show_favorites_only and not (mode == 'baseball_live' and show_all_live) and not self._is_favorite_game(game): - continue + if min_duration is not None and total_duration < min_duration: + total_duration = min_duration + self.logger.info( + f"get_cycle_duration: clamped {original_duration}s up to " + f"min_duration={min_duration}s" + ) - # Filter by game state and per-league limits - if mode == 'baseball_live' and status_state == 'in': - filtered.append(game) + if max_duration is not None and total_duration > max_duration: + total_duration = max_duration + self.logger.info( + f"get_cycle_duration: clamped {original_duration}s down to " + f"max_duration={max_duration}s" + ) - 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('is_final')]) - if recent_count >= recent_limit: - continue - filtered.append(game) + # Log the breakdown for mixed leagues + if len(duration_breakdown) > 1: + self.logger.info( + f"get_cycle_duration({display_mode}): mixed leagues - " + f"{', '.join(duration_breakdown)} = {total_duration}s total" + ) + else: + self.logger.info( + f"get_cycle_duration: {display_mode} = {total_games} games, " + f"total_duration={total_duration}s" + ) - 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('is_upcoming')]) - if upcoming_count >= upcoming_limit: - continue - filtered.append(game) + return total_duration + + except Exception as e: + self.logger.error(f"Error calculating cycle duration for {display_mode}: {e}") + return None - return filtered + def get_info(self) -> Dict[str, Any]: + """Get plugin information.""" + try: + current_manager = self._get_current_manager() + current_mode = self.modes[self.current_mode_index] if self.modes else "none" + + info = { + "plugin_id": self.plugin_id, + "name": "Baseball Scoreboard", + "version": "2.0.0", + "enabled": self.is_enabled, + "display_size": f"{self.display_width}x{self.display_height}", + "mlb_enabled": self.mlb_enabled, + "milb_enabled": self.milb_enabled, + "ncaa_baseball_enabled": self.ncaa_baseball_enabled, + "current_mode": current_mode, + "available_modes": self.modes, + "display_duration": self.display_duration, + "game_display_duration": self.game_display_duration, + "live_priority": { + "mlb": self.mlb_enabled and self.mlb_live_priority, + "milb": self.milb_enabled and self.milb_live_priority, + "ncaa_baseball": self.ncaa_baseball_enabled and self.ncaa_baseball_live_priority, + }, + "show_records": getattr(current_manager, "mode_config", {}).get( + "show_records" + ) + if current_manager + else None, + "show_ranking": getattr(current_manager, "mode_config", {}).get( + "show_ranking" + ) + if current_manager + else None, + "show_odds": getattr(current_manager, "mode_config", {}).get( + "show_odds" + ) + if current_manager + else None, + "managers_initialized": { + "mlb_live": hasattr(self, "mlb_live"), + "mlb_recent": hasattr(self, "mlb_recent"), + "mlb_upcoming": hasattr(self, "mlb_upcoming"), + "milb_live": hasattr(self, "milb_live"), + "milb_recent": hasattr(self, "milb_recent"), + "milb_upcoming": hasattr(self, "milb_upcoming"), + "ncaa_baseball_live": hasattr(self, "ncaa_baseball_live"), + "ncaa_baseball_recent": hasattr(self, "ncaa_baseball_recent"), + "ncaa_baseball_upcoming": hasattr(self, "ncaa_baseball_upcoming"), + }, + } - def _has_live_games(self) -> bool: - """Check if there are any live games available.""" - with self._games_lock: - return any(game.get('is_live') for game in self.current_games) + # Add manager-specific info if available + if current_manager and hasattr(current_manager, "get_info"): + try: + manager_info = current_manager.get_info() + info["current_manager_info"] = manager_info + except Exception as e: + info["current_manager_info"] = f"Error getting manager info: {e}" - def _has_recent_games(self) -> bool: - """Check if there are any recent games available.""" - with self._games_lock: - return any(game.get('is_final') for game in self.current_games) + return info - def has_live_content(self) -> bool: + except Exception as e: + self.logger.error(f"Error getting plugin info: {e}") + return { + "plugin_id": self.plugin_id, + "name": "Baseball Scoreboard", + "error": str(e), + } + + # ------------------------------------------------------------------ + # Dynamic duration hooks + # ------------------------------------------------------------------ + def reset_cycle_state(self) -> None: + """Reset dynamic cycle tracking. + + Note: We do NOT clear start times, progress, or display_mode_to_managers + because these need to persist across quick mode switches within the same plugin. + The 10-second threshold in _record_dynamic_progress handles true new cycles. + """ + super().reset_cycle_state() + self._dynamic_cycle_seen_modes.clear() + self._dynamic_mode_to_manager_key.clear() + # DO NOT clear these - let the 10-second threshold in _record_dynamic_progress handle it + # self._dynamic_manager_progress.clear() + # self._dynamic_managers_completed.clear() + self._dynamic_cycle_complete = False + # DO NOT clear start times - they need to persist until full duration elapsed + # self._single_game_manager_start_times.clear() # Keep for duration tracking + # self._game_id_start_times.clear() # Keep for duration tracking + # DO NOT clear display_mode_to_managers - the 10s threshold handles new cycles + # self._display_mode_to_managers.clear() + self.logger.debug("Dynamic cycle state reset - flags cleared, tracking preserved for multi-mode plugin cycle") + + def is_cycle_complete(self) -> bool: + """Report whether the plugin has shown a full cycle of content.""" + if not self._dynamic_feature_enabled(): + return True + + # Check if scroll mode is active for the current display mode + if self._current_active_display_mode: + mode_type = self._extract_mode_type(self._current_active_display_mode) + if mode_type and self._should_use_scroll_mode(mode_type) and self._scroll_manager: + # For scroll mode, check ScrollHelper's completion status + is_complete = self._scroll_manager.is_complete(mode_type) + self.logger.info(f"is_cycle_complete() [scroll mode]: display_mode={self._current_active_display_mode}, returning {is_complete}") + return is_complete + + # Pass the current active display mode to evaluate completion for the right mode + self._evaluate_dynamic_cycle_completion(display_mode=self._current_active_display_mode) + self.logger.info(f"is_cycle_complete() called: display_mode={self._current_active_display_mode}, returning {self._dynamic_cycle_complete}") + return self._dynamic_cycle_complete + + def _dynamic_feature_enabled(self) -> bool: + """Return True when dynamic duration should be active.""" + if not self.is_enabled: + return False + return self.supports_dynamic_duration() + + def supports_dynamic_duration(self) -> bool: """ - Override BasePlugin method to indicate when plugin has live content. - This is used by display controller for live priority system. + Check if dynamic duration is enabled for the current display context. + Checks granular settings: per-league/per-mode > per-mode > per-league > global. """ - return self._has_live_games() + if not self.is_enabled: + return False + + # If no current display context, return False (no global fallback) + if not self._current_display_league or not self._current_display_mode_type: + return False + + league = self._current_display_league + mode_type = self._current_display_mode_type + + # Check per-league/per-mode setting first (most specific) + league_config = self.config.get(league, {}) + league_dynamic = league_config.get("dynamic_duration", {}) + league_modes = league_dynamic.get("modes", {}) + mode_config = league_modes.get(mode_type, {}) + if "enabled" in mode_config: + return bool(mode_config.get("enabled", False)) + + # Check per-league setting + if "enabled" in league_dynamic: + return bool(league_dynamic.get("enabled", False)) + + # No global fallback - return False + return False - def get_live_modes(self) -> list: + def get_dynamic_duration_cap(self) -> Optional[float]: + """ + Get dynamic duration cap for the current display context. + Checks granular settings: per-league/per-mode > per-mode > per-league > global. """ - Override BasePlugin method to specify which modes to show during live priority. - Only show the live mode, not recent/upcoming. + if not self.is_enabled: + return None + + # If no current display context, return None (no global fallback) + if not self._current_display_league or not self._current_display_mode_type: + return None + + league = self._current_display_league + mode_type = self._current_display_mode_type + + # Check per-league/per-mode setting first (most specific) + league_config = self.config.get(league, {}) + league_dynamic = league_config.get("dynamic_duration", {}) + league_modes = league_dynamic.get("modes", {}) + mode_config = league_modes.get(mode_type, {}) + if "max_duration_seconds" in mode_config: + try: + cap = float(mode_config.get("max_duration_seconds")) + if cap > 0: + return cap + except (TypeError, ValueError): + pass + + # Check per-league setting + if "max_duration_seconds" in league_dynamic: + try: + cap = float(league_dynamic.get("max_duration_seconds")) + if cap > 0: + return cap + except (TypeError, ValueError): + pass + + # No global fallback - return None + return None + + def get_dynamic_duration_floor(self) -> Optional[float]: """ - return ['baseball_live'] + Get dynamic duration minimum (floor) for the current display context. + Checks granular settings: per-league/per-mode > per-league > None. - 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 + Returns: + Minimum duration in seconds, or None if not configured. + """ + if not self.is_enabled: + 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__)) - ledmatrix_root = None - for parent in [current_dir, os.path.dirname(current_dir), os.path.dirname(os.path.dirname(current_dir))]: - if os.path.exists(os.path.join(parent, 'assets', 'sports')): - ledmatrix_root = parent - break + # If no current display context, return None + if not self._current_display_league or not self._current_display_mode_type: + return None - if ledmatrix_root: - logo_dir = os.path.join(ledmatrix_root, logo_dir) - else: - logo_dir = os.path.abspath(logo_dir) + league = self._current_display_league + mode_type = self._current_display_mode_type - logo_path = Path(logo_dir) / f"{team_abbrev}.png" + # Check per-league/per-mode setting first (most specific) + league_config = self.config.get(league, {}) + league_dynamic = league_config.get("dynamic_duration", {}) + league_modes = league_dynamic.get("modes", {}) + mode_config = league_modes.get(mode_type, {}) + if "min_duration_seconds" in mode_config: + try: + floor = float(mode_config.get("min_duration_seconds")) + if floor > 0: + return floor + except (TypeError, ValueError): + pass + + # Check per-league setting + if "min_duration_seconds" in league_dynamic: + try: + floor = float(league_dynamic.get("min_duration_seconds")) + if floor > 0: + return floor + except (TypeError, ValueError): + pass - # 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') + # No global fallback - return None + return None - 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'] - 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): - found_path = potential_path - break - if found_path: - break + def _get_duration_floor_for_mode(self, mode_type: str) -> Optional[float]: + """ + Get the minimum duration floor for a mode type across all enabled leagues. - if not found_path: - return None + When both MLB and NCAA Baseball are enabled, returns the highest min_duration + configured across the enabled leagues (most restrictive floor). - 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), RESAMPLE_FILTER) + Args: + mode_type: Mode type ('live', 'recent', or 'upcoming') - return logo + Returns: + Minimum duration in seconds, or None if not configured. + """ + floors = [] - except Exception as e: - self.logger.debug(f"Could not load logo for {team_abbrev}: {e}") - return None + for league in ['mlb', 'milb', 'ncaa_baseball']: + league_config = self.config.get(league, {}) + if not league_config.get('enabled', False): + continue - def _draw_text_with_outline(self, draw: ImageDraw.Draw, text: str, position: tuple, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): - """Draw text with a black outline for better readability.""" - try: - x, y = position - # Draw outline - 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 main text - draw.text((x, y), text, font=font, fill=fill) - except Exception as e: - self.logger.error(f"Error drawing text with outline: {e}") + league_dynamic = league_config.get("dynamic_duration", {}) + league_modes = league_dynamic.get("modes", {}) + mode_config = league_modes.get(mode_type, {}) - def _display_game(self, game: Dict, mode: str): - """Display a single baseball game, routing to the appropriate renderer.""" - try: - 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: - self._display_recent_game(game) - except Exception as 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 + # Check per-mode setting first + if "min_duration_seconds" in mode_config: + try: + floor = float(mode_config.get("min_duration_seconds")) + if floor > 0: + floors.append(floor) + continue + except (TypeError, ValueError): + pass + + # Check per-league setting + if "min_duration_seconds" in league_dynamic: + try: + floor = float(league_dynamic.get("min_duration_seconds")) + if floor > 0: + floors.append(floor) + except (TypeError, ValueError): + pass - 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 + # Return the highest floor (most restrictive) + return max(floors) if floors else None - # 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) + def _get_duration_cap_for_mode(self, mode_type: str) -> Optional[float]: + """ + Get the maximum duration cap for a mode type across all enabled leagues. - # 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) + When both MLB and NCAA Baseball are enabled, returns the lowest max_duration + configured across the enabled leagues (most restrictive cap). - def _fetch_and_render_odds(self, draw: ImageDraw.Draw, game: Dict, width: int, height: int): - """Render pre-fetched odds for a game if enabled. + Args: + mode_type: Mode type ('live', 'recent', or 'upcoming') - Odds data is populated during update() cycle — this method only renders. + Returns: + Maximum duration in seconds, or None if not configured. """ - 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 + caps = [] - odds = game.get('odds') - if odds: - self._odds_manager.render_odds(draw, odds, width, height, self.fonts) + for league in ['mlb', 'milb', 'ncaa_baseball']: + league_config = self.config.get(league, {}) + if not league_config.get('enabled', False): + continue - 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 + league_dynamic = league_config.get("dynamic_duration", {}) + league_modes = league_dynamic.get("modes", {}) + mode_config = league_modes.get(mode_type, {}) - 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) + # Check per-mode setting first + if "max_duration_seconds" in mode_config: + try: + cap = float(mode_config.get("max_duration_seconds")) + if cap > 0: + caps.append(cap) + continue + except (TypeError, ValueError): + pass + + # Check per-league setting + if "max_duration_seconds" in league_dynamic: + try: + cap = float(league_dynamic.get("max_duration_seconds")) + if cap > 0: + caps.append(cap) + except (TypeError, ValueError): + pass + + # Return the lowest cap (most restrictive) + return min(caps) if caps else None + + def _get_manager_for_mode(self, mode_name: str): + """Resolve manager instance for a given display mode.""" + if mode_name.startswith("mlb_"): + if not self.mlb_enabled: + return None + suffix = mode_name.split("_", 1)[1] + if suffix == "live": + return getattr(self, "mlb_live", None) + if suffix == "recent": + return getattr(self, "mlb_recent", None) + if suffix == "upcoming": + return getattr(self, "mlb_upcoming", None) + elif mode_name.startswith("milb_"): + if not self.milb_enabled: + return None + suffix = mode_name[len("milb_"):] + if suffix == "live": + return getattr(self, "milb_live", None) + if suffix == "recent": + return getattr(self, "milb_recent", None) + if suffix == "upcoming": + return getattr(self, "milb_upcoming", None) + elif mode_name.startswith("ncaa_baseball_"): + if not self.ncaa_baseball_enabled: + return None + suffix = mode_name[len("ncaa_baseball_"):] + if suffix == "live": + return getattr(self, "ncaa_baseball_live", None) + if suffix == "recent": + return getattr(self, "ncaa_baseball_recent", None) + if suffix == "upcoming": + return getattr(self, "ncaa_baseball_upcoming", None) + return None - # 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 + def _get_manager_for_league_mode(self, league: str, mode_type: str): + """Get manager instance for a league and mode type combination. + + Args: + league: 'mlb', 'milb', or 'ncaa_baseball' + mode_type: 'live', 'recent', or 'upcoming' + + Returns: + Manager instance or None if not available/enabled + """ + if league == 'mlb' and not self.mlb_enabled: + return None + if league == 'milb' and not self.milb_enabled: + return None + if league == 'ncaa_baseball' and not self.ncaa_baseball_enabled: + return None + + attr_name = f"{league}_{mode_type}" + return getattr(self, attr_name, None) if hasattr(self, attr_name) else None - # --- 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 + def _has_live_games_for_manager(self, manager) -> bool: + """Check if a manager has valid live games (for favorite teams if configured). - outs_column_start_y = overall_start_y + (base_cluster_height // 2) - (out_cluster_height // 2) + Args: + manager: Manager instance to check - # Draw bases as diamond polygons - base_color_filled = (255, 255, 255) - base_color_outline = (255, 255, 255) - h_d = base_diamond_size // 2 + Returns: + True if manager has live games that should be displayed + """ + manager_name = getattr(manager, 'sport_key', type(manager).__name__) - # 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) + if not manager: + self.logger.debug("[LIVE_PRIORITY_DEBUG] _has_live_games_for_manager: manager is None") + return False - base_bottom_y = c2y + h_d + raw_live_games = getattr(manager, 'live_games', []) + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _has_live_games_for_manager({manager_name}): " + f"raw live_games count = {len(raw_live_games)}" + ) - # 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}" + if not raw_live_games: + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _has_live_games_for_manager({manager_name}): " + f"returning False - no raw live games" + ) + return False - 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 + # Filter out games that are final or appear over + live_games = [g for g in raw_live_games if not g.get('is_final', False)] + games_after_final_filter = len(live_games) + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _has_live_games_for_manager({manager_name}): " + f"after is_final filter = {games_after_final_filter} games" + ) - # Away (bottom left) - self._draw_text_with_outline(draw, away_text, (2, score_y), score_font) + if hasattr(manager, '_is_game_really_over'): + games_before = len(live_games) + live_games = [g for g in live_games if not manager._is_game_really_over(g)] + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _has_live_games_for_manager({manager_name}): " + f"after _is_game_really_over filter = {len(live_games)} games (removed {games_before - len(live_games)})" + ) - # 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 + if not live_games: + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _has_live_games_for_manager({manager_name}): " + f"returning False - no live games after filtering" + ) + return False - # "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 + # If favorite teams are configured, only return True if there are live games for favorite teams + favorite_teams = getattr(manager, 'favorite_teams', []) + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _has_live_games_for_manager({manager_name}): " + f"favorite_teams = {favorite_teams}" + ) - # "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, - self.display_manager.matrix.height), - (0, 0, 0)) - draw = ImageDraw.Draw(img) - - message = { - 'baseball_live': "No Live Games", - 'baseball_recent': "No Recent Games", - 'baseball_upcoming': "No Upcoming Games" - }.get(mode, "No Games") - - draw.text((5, 12), message, fill=(150, 150, 150)) - - self.display_manager.image = img.copy() - self.display_manager.update_display() - - def _display_error(self, message: str): - """Display error message.""" - img = Image.new('RGB', (self.display_manager.matrix.width, - self.display_manager.matrix.height), - (0, 0, 0)) - draw = ImageDraw.Draw(img) - draw.text((5, 12), message, fill=(255, 0, 0)) - - self.display_manager.image = img.copy() - self.display_manager.update_display() - - def get_display_duration(self) -> float: - """Get display duration from config.""" - return self.display_duration + if favorite_teams: + # Log each game's match status + for game in live_games: + home = game.get('home_abbr') + away = game.get('away_abbr') + home_match = home in favorite_teams + away_match = away in favorite_teams + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _has_live_games_for_manager({manager_name}): " + f"checking {away}@{home} - home_in_favorites={home_match}, away_in_favorites={away_match}" + ) - def get_info(self) -> Dict[str, Any]: - """Return plugin info for web UI.""" - info = super().get_info() - - # Get league-specific configurations - leagues_config = {} - for league_key, league_config in self.leagues.items(): - leagues_config[league_key] = { - 'enabled': league_config.get('enabled', False), - 'favorite_teams': league_config.get('favorite_teams', []), - 'display_modes': league_config.get('display_modes', {}), - 'recent_games_to_show': league_config.get('recent_games_to_show', 5), - 'upcoming_games_to_show': league_config.get('upcoming_games_to_show', 10), - 'update_interval_seconds': league_config.get('update_interval_seconds', 60) - } + has_favorite_live = any( + game.get('home_abbr') in favorite_teams + or game.get('away_abbr') in favorite_teams + for game in live_games + ) + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _has_live_games_for_manager({manager_name}): " + f"returning {has_favorite_live} - has_favorite_live check" + ) + return has_favorite_live - # 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('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, - 'enabled_leagues': [k for k, v in self.leagues.items() if v.get('enabled', False)], - 'current_mode': self.current_display_mode, - 'last_update': self.last_update, - 'display_duration': self.display_duration, - 'show_records': self.show_records, - 'show_ranking': self.show_ranking, - 'live_games': live_games, - 'recent_games': recent_games, - 'upcoming_games': upcoming_games, - 'leagues_config': leagues_config, - 'global_config': self.global_config - }) - return info + # No favorite teams configured, any live game counts + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _has_live_games_for_manager({manager_name}): " + f"returning True - no favorites configured, {len(live_games)} live games exist" + ) + return True - # ------------------------------------------------------------------------- - # Scroll mode helper methods - # ------------------------------------------------------------------------- - def _should_use_scroll_mode(self) -> bool: + def _filter_managers_by_live_content(self, managers: list, mode_type: str) -> list: + """Filter managers based on live content when in live mode. + + Args: + managers: List of manager instances + mode_type: 'live', 'recent', or 'upcoming' + + Returns: + Filtered list of managers with live content (for live mode) or original list """ - Check if scroll mode should be used. + if mode_type != 'live': + return managers + + # For live mode, only include managers with actual live games + filtered = [] + for manager in managers: + if self._has_live_games_for_manager(manager): + filtered.append(manager) + + return filtered + def _resolve_managers_for_mode(self, mode_type: str) -> list: + """ + Resolve ordered list of managers to try for a given mode type. + + This method uses the league registry to get managers in priority order, + respecting both league-level and mode-level enabling/disabling. + + For live mode, it also respects live_priority settings and filters + to only include managers with actual live games. + + Args: + mode_type: 'live', 'recent', or 'upcoming' + Returns: - True if scroll mode should be used, False otherwise + Ordered list of manager instances to try (in priority order) + Managers are filtered based on: + - League enabled state + - Mode enabled state for that league (show_live, show_recent, show_upcoming) + - For live mode: live_priority and actual live games availability """ - # Check if scroll manager is available - if not self._scroll_manager: - return False - - # Scroll mode is always preferred if available - return True + managers_to_try = [] + + # Get enabled leagues for this mode type in priority order + # This already respects league-level and mode-level enabling + enabled_leagues = self._get_enabled_leagues_for_mode(mode_type) + + if mode_type == 'live': + # For live mode, update managers first to get current live games + # This ensures we have fresh data before checking for live content + for league_id in enabled_leagues: + manager = self._get_league_manager_for_mode(league_id, 'live') + if manager: + try: + manager.update() + except Exception as e: + self.logger.debug(f"Error updating {league_id} live manager: {e}") + + # For live mode, respect live_priority settings + # Only include managers with live_priority enabled AND actual live games + for league_id in enabled_leagues: + league_data = self._league_registry.get(league_id, {}) + live_priority = league_data.get('live_priority', False) + + manager = self._get_league_manager_for_mode(league_id, 'live') + if not manager: + continue + + # If live_priority is enabled, only include if manager has live games + if live_priority: + if self._has_live_games_for_manager(manager): + managers_to_try.append(manager) + self.logger.debug( + f"{league_id} has live games and live_priority - adding to list" + ) + else: + # No live_priority - include manager anyway (fallback) + managers_to_try.append(manager) + self.logger.debug( + f"{league_id} live manager added (no live_priority requirement)" + ) + + # If no managers found with live_priority, fall back to all enabled managers + # This ensures we always have something to show if leagues are enabled + if not managers_to_try: + for league_id in enabled_leagues: + manager = self._get_league_manager_for_mode(league_id, 'live') + if manager: + managers_to_try.append(manager) + self.logger.debug( + f"Fallback: added {league_id} live manager (no live_priority managers found)" + ) + else: + # For recent and upcoming modes, use standard priority order + # Get managers for each enabled league in priority order + for league_id in enabled_leagues: + manager = self._get_league_manager_for_mode(league_id, mode_type) + if manager: + managers_to_try.append(manager) + self.logger.debug( + f"Added {league_id} {mode_type} manager to list " + f"(priority: {self._league_registry[league_id].get('priority', 999)})" + ) + + self.logger.debug( + f"Resolved {len(managers_to_try)} manager(s) for {mode_type} mode: " + f"{[m.__class__.__name__ for m in managers_to_try]}" + ) + + return managers_to_try - def _collect_games_for_scroll(self) -> tuple: + def _extract_mode_type(self, display_mode: str) -> Optional[str]: + """Extract mode type (live, recent, upcoming) from display mode string. + + Args: + display_mode: Display mode string (e.g., 'mlb_live', 'mlb_recent', 'ncaa_baseball_upcoming') + + Returns: + Mode type string ('live', 'recent', 'upcoming') or None """ - Collect all games for scroll mode from enabled leagues. - - Collects live, recent, and upcoming games organized by league. - Within each league, games are sorted: live first, then recent, then upcoming. + if display_mode.endswith('_live'): + return 'live' + elif display_mode.endswith('_recent'): + return 'recent' + elif display_mode.endswith('_upcoming'): + return 'upcoming' + return None - Returns: - Tuple of (games_list, leagues_list) + def _set_display_context_from_manager(self, manager, mode_type: str) -> None: + """Set current display league and mode type based on manager instance. + + Args: + manager: Manager instance + mode_type: 'live', 'recent', or 'upcoming' + """ + self._current_display_mode_type = mode_type + + if manager in (getattr(self, 'mlb_live', None), + getattr(self, 'mlb_recent', None), + getattr(self, 'mlb_upcoming', None)): + self._current_display_league = 'mlb' + elif manager in (getattr(self, 'milb_live', None), + getattr(self, 'milb_recent', None), + getattr(self, 'milb_upcoming', None)): + self._current_display_league = 'milb' + elif manager in (getattr(self, 'ncaa_baseball_live', None), + getattr(self, 'ncaa_baseball_recent', None), + getattr(self, 'ncaa_baseball_upcoming', None)): + self._current_display_league = 'ncaa_baseball' + + def _track_single_game_progress(self, manager_key: str, manager, league: str, mode_type: str) -> None: + """Track progress for a manager with a single game (or no games). + + Args: + manager_key: Unique key identifying this manager + manager: Manager instance + league: League name ('mlb' or 'ncaa_baseball') + mode_type: Mode type ('live', 'recent', or 'upcoming') """ - # Make a copy of games list under lock for thread safety - with self._games_lock: - games_copy = list(self.current_games) + current_time = time.time() + + if manager_key not in self._single_game_manager_start_times: + # First time seeing this single-game manager (in this cycle) - record start time + self._single_game_manager_start_times[manager_key] = current_time + game_duration = self._get_game_duration(league, mode_type, manager) if league and mode_type else getattr(manager, 'game_display_duration', 15) + self.logger.info(f"Single-game manager {manager_key} first seen at {current_time:.2f}, will complete after {game_duration}s") + else: + # Check if enough time has passed + start_time = self._single_game_manager_start_times[manager_key] + game_duration = self._get_game_duration(league, mode_type, manager) if league and mode_type else getattr(manager, 'game_display_duration', 15) + elapsed = current_time - start_time + if elapsed >= game_duration: + # Enough time has passed - mark as complete + if manager_key not in self._dynamic_managers_completed: + self._dynamic_managers_completed.add(manager_key) + self.logger.info(f"Single-game manager {manager_key} completed after {elapsed:.2f}s (required: {game_duration}s)") + # Clean up start time now that manager has completed + if manager_key in self._single_game_manager_start_times: + del self._single_game_manager_start_times[manager_key] + else: + # Still waiting + self.logger.debug(f"Single-game manager {manager_key} waiting: {elapsed:.2f}s/{game_duration}s (start_time={start_time:.2f}, current_time={current_time:.2f})") - # Group games by league first - games_by_league = {} + def _record_dynamic_progress(self, current_manager, actual_mode: str = None, display_mode: str = None) -> None: + """Track progress through managers/games for dynamic duration.""" + if not self._dynamic_feature_enabled() or not self.modes: + self._dynamic_cycle_complete = True + return - for game in games_copy: - league = game.get('league', 'mlb') + # Use actual_mode if provided (when display_mode is specified), otherwise use internal mode cycling + if actual_mode: + current_mode = actual_mode + else: + current_mode = self.modes[self.current_mode_index] + + # Track both the internal mode and the external display mode if provided + self._dynamic_cycle_seen_modes.add(current_mode) + if display_mode and display_mode != current_mode: + # Also track the external display mode for proper completion checking + self._dynamic_cycle_seen_modes.add(display_mode) + + manager_key = self._build_manager_key(current_mode, current_manager) + self._dynamic_mode_to_manager_key[current_mode] = manager_key + + # Extract league and mode_type from current_mode for duration lookups + league = None + mode_type = None + if current_mode: + if current_mode.startswith('mlb_'): + league = 'mlb' + mode_type = current_mode.split('_', 1)[1] + elif current_mode.startswith('milb_'): + league = 'milb' + mode_type = current_mode.split('_', 1)[1] + elif current_mode.startswith('ncaa_baseball_'): + league = 'ncaa_baseball' + mode_type = current_mode.split('_', 2)[2] + + # Log for debugging + self.logger.debug(f"_record_dynamic_progress: current_mode={current_mode}, display_mode={display_mode}, manager={current_manager.__class__.__name__}, manager_key={manager_key}, _last_display_mode={self._last_display_mode}") - # Check if league is enabled - if not self.leagues.get(league, {}).get('enabled', False): - continue + total_games = self._get_total_games_for_manager(current_manager) + + # Check if this is a new cycle for this display mode BEFORE adding to tracking + # A "new cycle" means we're returning to a mode after having been away (different mode) + # Only track external display_mode (from display controller), not internal mode cycling + is_new_cycle = False + current_time = time.time() + + # Only track mode changes for external calls (where display_mode differs from actual_mode) + # This prevents internal mode cycling from triggering new cycle detection + is_external_call = (display_mode and actual_mode and display_mode != actual_mode) + + if is_external_call: + # External call from display controller - check for mode switches + # Only treat as "new cycle" if we've been away for a while (> 10s) + # This allows cycling through recent→upcoming→live→recent without clearing state + NEW_CYCLE_THRESHOLD = 10.0 # seconds + + if display_mode != self._last_display_mode: + # Switched to a different external mode + time_since_last = current_time - self._last_display_mode_time if self._last_display_mode_time > 0 else 999 + + # Only treat as new cycle if we've been away for a while OR this is the first time + if time_since_last >= NEW_CYCLE_THRESHOLD: + is_new_cycle = True + self.logger.info(f"New cycle detected for {display_mode}: switched from {self._last_display_mode} (last seen {time_since_last:.1f}s ago)") + else: + # Quick mode switch within same overall cycle - don't reset + self.logger.debug(f"Quick mode switch to {display_mode} from {self._last_display_mode} ({time_since_last:.1f}s ago) - continuing cycle") + elif manager_key not in self._display_mode_to_managers.get(display_mode, set()): + # Same external mode but manager not tracked yet - could be multi-league setup + self.logger.debug(f"Manager {manager_key} not yet tracked for current mode {display_mode}") + else: + # Same mode and manager already tracked - continue within current cycle + self.logger.debug(f"Continuing cycle for {display_mode}: manager {manager_key} already tracked") + + # Update last display mode tracking (only for external calls) + self._last_display_mode = display_mode + self._last_display_mode_time = current_time + + # ONLY reset state if this is truly a new cycle (after threshold) + if is_new_cycle: + # New cycle starting - reset ALL state for this manager to start completely fresh + if manager_key in self._single_game_manager_start_times: + old_start = self._single_game_manager_start_times[manager_key] + self.logger.info(f"New cycle for {display_mode}: resetting start time for {manager_key} (old: {old_start:.2f})") + del self._single_game_manager_start_times[manager_key] + # Also remove from completed set so it can be tracked fresh in this cycle + if manager_key in self._dynamic_managers_completed: + self.logger.info(f"New cycle for {display_mode}: removing {manager_key} from completed set") + self._dynamic_managers_completed.discard(manager_key) + # Also clear any game ID start times for this manager + if manager_key in self._game_id_start_times: + self.logger.info(f"New cycle for {display_mode}: clearing game ID start times for {manager_key}") + del self._game_id_start_times[manager_key] + # Clear progress tracking for this manager + if manager_key in self._dynamic_manager_progress: + self.logger.info(f"New cycle for {display_mode}: clearing progress for {manager_key}") + self._dynamic_manager_progress[manager_key].clear() + + # Now add to tracking AFTER checking for new cycle + if display_mode and display_mode != current_mode: + # Store mapping from display_mode to manager_key for completion checking + self._display_mode_to_managers.setdefault(display_mode, set()).add(manager_key) + + if total_games <= 1: + # Single (or no) game - wait for full game display duration before marking complete + self._track_single_game_progress(manager_key, current_manager, league, mode_type) + return - # Determine game type from state - if game.get('is_live'): - game_type = 'live' - elif game.get('is_final'): - game_type = 'recent' - elif game.get('is_upcoming'): - game_type = 'upcoming' + # Get current game to extract its ID for tracking + current_game = getattr(current_manager, "current_game", None) + if not current_game: + # No current game - can't track progress, but this is valid (empty game list) + self.logger.debug(f"No current_game in manager {manager_key}, skipping progress tracking") + # Still mark the mode as seen even if no content + return + + # Use game ID for tracking instead of index to persist across game order changes + game_id = current_game.get('id') + if not game_id: + # Fallback to index if game ID not available (shouldn't happen, but safety first) + current_index = getattr(current_manager, "current_game_index", 0) + # Also try to get a unique identifier from game data + away_abbr = current_game.get('away_abbr', '') + home_abbr = current_game.get('home_abbr', '') + if away_abbr and home_abbr: + game_id = f"{away_abbr}@{home_abbr}-{current_index}" else: - continue + game_id = f"index-{current_index}" + self.logger.warning(f"Game ID not found for manager {manager_key}, using fallback: {game_id}") + + # Ensure game_id is a string for consistent tracking + game_id = str(game_id) + + progress_set = self._dynamic_manager_progress.setdefault(manager_key, set()) + + # Track when this game ID was first seen + game_times = self._game_id_start_times.setdefault(manager_key, {}) + if game_id not in game_times: + # First time seeing this game - record start time + game_times[game_id] = time.time() + game_duration = self._get_game_duration(league, mode_type, current_manager) if league and mode_type else getattr(current_manager, 'game_display_duration', 15) + game_display = f"{current_game.get('away_abbr', '?')}@{current_game.get('home_abbr', '?')}" + self.logger.info(f"Game {game_display} (ID: {game_id}) in manager {manager_key} first seen, will complete after {game_duration}s") + + # Check if this game has been shown for full duration + start_time = game_times[game_id] + game_duration = self._get_game_duration(league, mode_type, current_manager) if league and mode_type else getattr(current_manager, 'game_display_duration', 15) + elapsed = time.time() - start_time + + if elapsed >= game_duration: + # This game has been shown for full duration - add to progress set + if game_id not in progress_set: + progress_set.add(game_id) + game_display = f"{current_game.get('away_abbr', '?')}@{current_game.get('home_abbr', '?')}" + self.logger.info(f"Game {game_display} (ID: {game_id}) in manager {manager_key} completed after {elapsed:.2f}s (required: {game_duration}s)") + else: + # Still waiting for this game to complete its duration + self.logger.debug(f"Game ID {game_id} in manager {manager_key} waiting: {elapsed:.2f}s/{game_duration}s") - # Check if this game type is enabled for this league - display_modes = self.leagues.get(league, {}).get('display_modes', {}) - if not display_modes.get(game_type, False): - continue + # Get all valid game IDs from current game list to clean up stale entries + valid_game_ids = self._get_all_game_ids_for_manager(current_manager) + + # Clean up progress set and start times for games that no longer exist + if valid_game_ids: + # Remove game IDs from progress set that are no longer in the game list + progress_set.intersection_update(valid_game_ids) + # Also clean up start times for games that no longer exist + game_times = {k: v for k, v in game_times.items() if k in valid_game_ids} + self._game_id_start_times[manager_key] = game_times + elif total_games == 0: + # No games in list - clear all tracking for this manager + progress_set.clear() + game_times.clear() + self._game_id_start_times[manager_key] = {} + + # Only mark manager complete when all current games have been shown for their full duration + # Use the actual current game IDs, not just the count, to handle dynamic game lists + current_game_ids = self._get_all_game_ids_for_manager(current_manager) + + if current_game_ids: + # Check if all current games have been shown for full duration + if current_game_ids.issubset(progress_set): + if manager_key not in self._dynamic_managers_completed: + self._dynamic_managers_completed.add(manager_key) + self.logger.info(f"Manager {manager_key} completed - all {len(current_game_ids)} games shown for full duration (progress: {len(progress_set)} game IDs)") + else: + missing_count = len(current_game_ids - progress_set) + self.logger.debug(f"Manager {manager_key} incomplete - {missing_count} of {len(current_game_ids)} games not yet shown for full duration") + elif total_games == 0: + # Empty game list - mark as complete immediately + if manager_key not in self._dynamic_managers_completed: + self._dynamic_managers_completed.add(manager_key) + self.logger.debug(f"Manager {manager_key} completed - no games to display") + + def _evaluate_dynamic_cycle_completion(self, display_mode: str = None) -> None: + """ + Determine whether all enabled leagues have completed their cycles for a display mode. + + For sequential block display, a display mode cycle is complete when: + - All enabled leagues for that mode type have completed showing all their games + - Each league is tracked separately via manager keys + + This method checks completion status for all leagues that were used for + the given display mode, ensuring both MLB and NCAA Baseball (and future leagues) + have completed before marking the cycle as complete. + + Args: + display_mode: External display mode name (e.g., 'mlb_recent' or 'ncaa_baseball_recent') + If None, checks internal mode cycling completion + """ + if not self._dynamic_feature_enabled(): + self._dynamic_cycle_complete = True + return + + if not self.modes: + self._dynamic_cycle_complete = True + return - # Add to league's games - if league not in games_by_league: - games_by_league[league] = [] - games_by_league[league].append(game) + # If display_mode is provided, check all managers used for that display mode + # This handles multi-league scenarios where we need all leagues to complete + if display_mode and display_mode in self._display_mode_to_managers: + used_manager_keys = self._display_mode_to_managers[display_mode] + if not used_manager_keys: + # No managers were used for this display mode yet - cycle not complete + self._dynamic_cycle_complete = False + self.logger.debug(f"Display mode {display_mode} has no managers tracked yet - cycle incomplete") + return + + # Extract mode type to get enabled leagues for comparison + mode_type = self._extract_mode_type(display_mode) + enabled_leagues = self._get_enabled_leagues_for_mode(mode_type) if mode_type else [] + + self.logger.info( + f"_evaluate_dynamic_cycle_completion for {display_mode}: " + f"checking {len(used_manager_keys)} manager(s): {used_manager_keys}, " + f"enabled leagues: {enabled_leagues}" + ) + + # Check if all managers used for this display mode have completed + incomplete_managers = [] + for manager_key in used_manager_keys: + if manager_key not in self._dynamic_managers_completed: + incomplete_managers.append(manager_key) + # Get the manager to check its state for logging and potential completion + # Extract mode and manager class from manager_key (format: "mode:ManagerClass") + parts = manager_key.split(':', 1) + if len(parts) == 2: + mode_name, manager_class_name = parts + manager = self._get_manager_for_mode(mode_name) + if manager and manager.__class__.__name__ == manager_class_name: + total_games = self._get_total_games_for_manager(manager) + if total_games <= 1: + # Single-game manager - check time + if manager_key in self._single_game_manager_start_times: + start_time = self._single_game_manager_start_times[manager_key] + # Extract league and mode_type from mode_name + league = 'mlb' if mode_name.startswith('mlb_') else ('milb' if mode_name.startswith('milb_') else ('ncaa_baseball' if mode_name.startswith('ncaa_baseball_') else None)) + mode_type = mode_name.split('_')[-1] if mode_name else None + game_duration = self._get_game_duration(league, mode_type, manager) if league and mode_type else getattr(manager, 'game_display_duration', 15) + current_time = time.time() + elapsed = current_time - start_time + if elapsed >= game_duration: + self._dynamic_managers_completed.add(manager_key) + incomplete_managers.remove(manager_key) + self.logger.info(f"Manager {manager_key} marked complete in completion check: {elapsed:.2f}s >= {game_duration}s") + # Clean up start time now that manager has completed + if manager_key in self._single_game_manager_start_times: + del self._single_game_manager_start_times[manager_key] + else: + self.logger.debug(f"Manager {manager_key} waiting in completion check: {elapsed:.2f}s/{game_duration}s (start_time={start_time:.2f}, current_time={current_time:.2f})") + else: + # Manager not yet seen - keep it incomplete + # This means _record_dynamic_progress hasn't been called yet for this manager + # or the state was reset, so we can't determine completion + self.logger.debug(f"Manager {manager_key} not yet seen in completion check (not in start_times) - keeping incomplete") + # Don't remove from incomplete_managers - it stays incomplete + else: + # Multi-game manager - check if all current games have been shown for full duration + progress_set = self._dynamic_manager_progress.get(manager_key, set()) + current_game_ids = self._get_all_game_ids_for_manager(manager) + + # Check if all current games are in the progress set (shown for full duration) + if current_game_ids and current_game_ids.issubset(progress_set): + self._dynamic_managers_completed.add(manager_key) + incomplete_managers.remove(manager_key) + else: + missing_games = current_game_ids - progress_set + self.logger.debug(f"Manager {manager_key} progress: {len(progress_set)}/{len(current_game_ids)} games completed, missing: {len(missing_games)}") + + self.logger.info(f"_evaluate_dynamic_cycle_completion for {display_mode}: incomplete_managers={incomplete_managers}, completed={[k for k in used_manager_keys if k in self._dynamic_managers_completed]}") + + if not incomplete_managers: + # All managers have completed - but verify they actually completed in THIS cycle + # Check that all managers either: + # 1. Are in _dynamic_managers_completed AND have no start time (truly completed) + # 2. Or have a start time that has elapsed (completed in this check) + all_truly_completed = True + for manager_key in used_manager_keys: + # If manager has a start time, it hasn't completed yet (or just completed) + if manager_key in self._single_game_manager_start_times: + # Still has start time - check if it should be completed + parts = manager_key.split(':', 1) + if len(parts) == 2: + mode_name, manager_class_name = parts + manager = self._get_manager_for_mode(mode_name) + if manager and manager.__class__.__name__ == manager_class_name: + start_time = self._single_game_manager_start_times[manager_key] + # Extract league and mode_type from mode_name + league = 'mlb' if mode_name.startswith('mlb_') else ('milb' if mode_name.startswith('milb_') else ('ncaa_baseball' if mode_name.startswith('ncaa_baseball_') else None)) + mode_type = mode_name.split('_')[-1] if mode_name else None + game_duration = self._get_game_duration(league, mode_type, manager) if league and mode_type else getattr(manager, 'game_display_duration', 15) + elapsed = time.time() - start_time + if elapsed < game_duration: + # Not enough time has passed - not truly completed + all_truly_completed = False + self.logger.debug(f"Manager {manager_key} in completed set but still has start time with {elapsed:.2f}s < {game_duration}s") + break + + if all_truly_completed: + self._dynamic_cycle_complete = True + self.logger.info(f"Display mode {display_mode} cycle complete - all {len(used_manager_keys)} manager(s) completed") + + # Reset mode start time since full cycle is complete + # This ensures next cycle starts timing from beginning + if display_mode in self._mode_start_time: + del self._mode_start_time[display_mode] + self.logger.debug(f"Reset mode start time for {display_mode} (full cycle complete)") + else: + # Some managers aren't truly completed - keep cycle incomplete + self._dynamic_cycle_complete = False + self.logger.debug(f"Display mode {display_mode} cycle incomplete - some managers not truly completed yet") + else: + self._dynamic_cycle_complete = False + self.logger.debug(f"Display mode {display_mode} cycle incomplete - {len(incomplete_managers)} manager(s) still in progress: {incomplete_managers}") + return - # Flatten games list, keeping leagues together - all_games = [] - leagues = list(games_by_league.keys()) + # Standard mode checking (for internal mode cycling) + required_modes = [mode for mode in self.modes if mode] + if not required_modes: + self._dynamic_cycle_complete = True + return - 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: ( - 0 if g.get('is_live') else 1 if g.get('is_final') else 2 - )) + for mode_name in required_modes: + if mode_name not in self._dynamic_cycle_seen_modes: + self._dynamic_cycle_complete = False + return - all_games.extend(league_games) + manager_key = self._dynamic_mode_to_manager_key.get(mode_name) + if not manager_key: + self._dynamic_cycle_complete = False + return - return all_games, leagues + if manager_key not in self._dynamic_managers_completed: + manager = self._get_manager_for_mode(mode_name) + total_games = self._get_total_games_for_manager(manager) + if total_games <= 1: + # For single-game managers, check if enough time has passed + if manager_key in self._single_game_manager_start_times: + start_time = self._single_game_manager_start_times[manager_key] + # Extract league and mode_type from mode_name + league = 'mlb' if mode_name.startswith('mlb_') else ('milb' if mode_name.startswith('milb_') else ('ncaa_baseball' if mode_name.startswith('ncaa_baseball_') else None)) + mode_type = mode_name.split('_')[-1] if mode_name else None + game_duration = self._get_game_duration(league, mode_type, manager) if (league and mode_type and manager) else (getattr(manager, 'game_display_duration', 15) if manager else 15) + elapsed = time.time() - start_time + if elapsed >= game_duration: + self._dynamic_managers_completed.add(manager_key) + else: + # Not enough time yet + self._dynamic_cycle_complete = False + return + else: + # Haven't seen this manager yet in _record_dynamic_progress + self._dynamic_cycle_complete = False + return + else: + # Multi-game manager - check if all current games have been shown for full duration + progress_set = self._dynamic_manager_progress.get(manager_key, set()) + current_game_ids = self._get_all_game_ids_for_manager(manager) + + # Check if all current games are in the progress set (shown for full duration) + if current_game_ids and current_game_ids.issubset(progress_set): + self._dynamic_managers_completed.add(manager_key) + # Continue to check other modes + else: + missing_games = current_game_ids - progress_set if current_game_ids else set() + self.logger.debug(f"Manager {manager_key} progress: {len(progress_set)}/{len(current_game_ids)} games completed, missing: {len(missing_games)}") + self._dynamic_cycle_complete = False + return + + self._dynamic_cycle_complete = True + + @staticmethod + def _build_manager_key(mode_name: str, manager) -> str: + manager_name = manager.__class__.__name__ if manager else "None" + return f"{mode_name}:{manager_name}" + + @staticmethod + def _get_total_games_for_manager(manager) -> int: + if manager is None: + return 0 + for attr in ("live_games", "games_list", "recent_games", "upcoming_games"): + value = getattr(manager, attr, None) + if isinstance(value, list): + return len(value) + return 0 + + @staticmethod + def _get_all_game_ids_for_manager(manager) -> set: + """Get all game IDs from a manager's game list.""" + if manager is None: + return set() + game_ids = set() + for attr in ("live_games", "games_list", "recent_games", "upcoming_games"): + game_list = getattr(manager, attr, None) + if isinstance(game_list, list) and game_list: + for i, game in enumerate(game_list): + game_id = game.get('id') + if game_id: + game_ids.add(str(game_id)) + else: + # Fallback to index-based identifier if ID missing + away_abbr = game.get('away_abbr', '') + home_abbr = game.get('home_abbr', '') + if away_abbr and home_abbr: + game_ids.add(f"{away_abbr}@{home_abbr}-{i}") + else: + game_ids.add(f"index-{i}") + break + return game_ids # ------------------------------------------------------------------------- # Vegas scroll mode support @@ -1489,7 +3635,6 @@ def get_vegas_content(self) -> Optional[Any]: """ # Ensure scroll content is generated for Vegas mode if hasattr(self, '_scroll_manager') and self._scroll_manager: - # Check if any scroll display has content using public method if not self._scroll_manager.has_cached_content(): self.logger.info("[Baseball Vegas] Triggering scroll content generation") self._ensure_scroll_content_for_vegas() @@ -1538,8 +3683,22 @@ def _ensure_scroll_content_for_vegas(self) -> None: self.logger.debug("[Baseball Vegas] No scroll manager available") return + # Refresh internal managers/cache so Vegas has up-to-date content + try: + if hasattr(self, 'update') and callable(self.update): + self.update() + self.logger.debug("[Baseball Vegas] Refreshed managers via update()") + elif hasattr(self, 'refresh_managers') and callable(self.refresh_managers): + self.refresh_managers() + self.logger.debug("[Baseball Vegas] Refreshed managers via refresh_managers()") + elif hasattr(self, '_update') and callable(self._update): + self._update() + self.logger.debug("[Baseball Vegas] Refreshed managers via _update()") + except Exception as e: + self.logger.debug(f"[Baseball Vegas] Manager refresh failed (non-fatal): {e}") + # Collect all games (live, recent, upcoming) organized by league - games, leagues = self._collect_games_for_scroll() + games, leagues = self._collect_games_for_scroll(live_priority_active=False) if not games: self.logger.debug("[Baseball Vegas] No games available") @@ -1548,18 +3707,21 @@ 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: - if game.get('is_live'): + state = game.get('status', {}).get('state', '') + if state == 'in': game_type_counts['live'] += 1 - elif game.get('is_final'): + elif state == 'post': game_type_counts['recent'] += 1 - elif game.get('is_upcoming'): + elif state == 'pre': game_type_counts['upcoming'] += 1 + # Get rankings cache if available + rankings_cache = self._get_rankings_cache() if hasattr(self, '_get_rankings_cache') else None + # Prepare scroll content with mixed game types # Note: Using 'mixed' as game_type indicator for scroll config - rankings = self._get_rankings_cache() if self.show_ranking else None success = self._scroll_manager.prepare_and_display( - games, 'mixed', leagues, rankings + games, 'mixed', leagues, rankings_cache ) if success: @@ -1573,12 +3735,12 @@ def _ensure_scroll_content_for_vegas(self) -> None: else: self.logger.warning("[Baseball Vegas] Failed to generate scroll content") - def cleanup(self) -> None: - """Cleanup resources.""" - with self._games_lock: - self.current_games = [] - if hasattr(self, 'data_manager') and self.data_manager: - # Background service is a global singleton, no explicit shutdown needed - self.data_manager = None - self.logger.info("Baseball scoreboard plugin cleaned up") + """Clean up resources.""" + try: + if hasattr(self, "background_service") and self.background_service: + # Clean up background service if needed + pass + self.logger.info("Baseball scoreboard plugin cleanup completed") + except Exception as e: + self.logger.error(f"Error during cleanup: {e}") diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 5ef9931..706d69d 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.3.0", + "version": "2.0.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -16,9 +16,15 @@ "live-scores" ], "display_modes": [ - "baseball_live", - "baseball_recent", - "baseball_upcoming" + "mlb_live", + "mlb_recent", + "mlb_upcoming", + "milb_live", + "milb_recent", + "milb_upcoming", + "ncaa_baseball_live", + "ncaa_baseball_recent", + "ncaa_baseball_upcoming" ], "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins", "branch": "main", @@ -26,7 +32,7 @@ "versions": [ { "released": "2026-02-14", - "version": "1.3.0", + "version": "2.0.0", "ledmatrix_min": "2.0.0" }, { diff --git a/plugins/baseball-scoreboard/milb_managers.py b/plugins/baseball-scoreboard/milb_managers.py new file mode 100644 index 0000000..d2a4012 --- /dev/null +++ b/plugins/baseball-scoreboard/milb_managers.py @@ -0,0 +1,386 @@ +import logging +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, List, Optional + +import pytz + +from baseball import Baseball, BaseballLive, BaseballRecent +from sports import SportsUpcoming + +# MiLB uses the MLB Stats API (ESPN does not support MiLB scoreboard) +MLB_STATS_BASE_URL = "http://statsapi.mlb.com/api/v1" +# Default MiLB sport IDs: AAA=11, AA=12, High-A=13, Single-A=14 +DEFAULT_MILB_SPORT_IDS = [11, 12, 13, 14] + + +class BaseMiLBManager(Baseball): + """Base class for MiLB managers with common functionality. + + Unlike other sports that use ESPN's scoreboard API, MiLB uses the MLB Stats API + (statsapi.mlb.com) since ESPN does not provide a MiLB scoreboard endpoint. + Data is fetched from the Stats API and converted to ESPN-compatible format + so it works seamlessly with the SportsCore extraction pipeline. + """ + + # Class variables for warning tracking + _no_data_warning_logged = False + _last_warning_time = 0 + _warning_cooldown = 60 # Only log warnings once per minute + _shared_data = None + _last_shared_update = 0 + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): + self.logger = logging.getLogger("MiLB") + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="milb", + ) + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.live_enabled = display_modes.get("milb_live", False) + self.recent_enabled = display_modes.get("milb_recent", False) + self.upcoming_enabled = display_modes.get("milb_upcoming", False) + + # MiLB sport IDs to fetch (configurable) + self.milb_sport_ids = self.mode_config.get( + "sport_ids", DEFAULT_MILB_SPORT_IDS + ) + + self.logger.info( + f"Initialized MiLB manager with display dimensions: {self.display_width}x{self.display_height}" + ) + self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info( + f"Display modes - Live: {self.live_enabled}, Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}" + ) + self.league = "minor-league-baseball" + + @staticmethod + def _convert_stats_game_to_espn_event(game: Dict) -> Dict: + """Convert a single MLB Stats API game to ESPN event format. + + This allows the SportsCore extraction pipeline (_extract_game_details_common + and _extract_game_details) to process MiLB data identically to ESPN data. + """ + home = game.get("teams", {}).get("home", {}) + away = game.get("teams", {}).get("away", {}) + home_team = home.get("team", {}) + away_team = away.get("team", {}) + status_obj = game.get("status", {}) + linescore = game.get("linescore", {}) + + # Map status + abstract_state = status_obj.get("abstractGameState", "Preview") + detailed_state = status_obj.get("detailedState", "") + + if abstract_state == "Live": + state = "in" + status_name = "STATUS_IN_PROGRESS" + elif abstract_state == "Final": + state = "post" + status_name = "STATUS_FINAL" + else: + state = "pre" + status_name = "STATUS_SCHEDULED" + + # Build inning detail text + current_inning = linescore.get("currentInning", 1) + inning_state = linescore.get("inningState", "") + inning_ordinal = linescore.get("currentInningOrdinal", f"{current_inning}") + + if abstract_state == "Final": + detail_text = "Final" + short_detail = "Final" + if current_inning and current_inning != 9: + detail_text = f"Final/{current_inning}" + short_detail = f"Final/{current_inning}" + elif abstract_state == "Live": + half = "Top" if inning_state.lower().startswith("top") else "Bot" + if "mid" in inning_state.lower(): + half = "Mid" + elif "end" in inning_state.lower(): + half = "End" + detail_text = f"{inning_state} {inning_ordinal}" + short_detail = f"{half} {inning_ordinal}" + else: + game_date = game.get("gameDate", "") + detail_text = detailed_state or "Scheduled" + short_detail = detail_text + + # Build records + home_record = home.get("leagueRecord", {}) + away_record = away.get("leagueRecord", {}) + home_record_summary = "" + away_record_summary = "" + if home_record.get("wins") is not None: + home_record_summary = f"{home_record.get('wins', 0)}-{home_record.get('losses', 0)}" + if away_record.get("wins") is not None: + away_record_summary = f"{away_record.get('wins', 0)}-{away_record.get('losses', 0)}" + + # Build situation (for live games) + situation = {} + if abstract_state == "Live" and linescore: + offense = linescore.get("offense", {}) + situation = { + "outs": linescore.get("outs", 0), + "onFirst": "first" in offense, + "onSecond": "second" in offense, + "onThird": "third" in offense, + "count": { + "balls": linescore.get("balls", 0), + "strikes": linescore.get("strikes", 0), + }, + } + + # Build ESPN-compatible event structure + event = { + "id": str(game.get("gamePk", "")), + "date": game.get("gameDate", ""), + "name": f"{away_team.get('name', '')} at {home_team.get('name', '')}", + "competitions": [ + { + "competitors": [ + { + "homeAway": "home", + "team": { + "id": str(home_team.get("id", "")), + "abbreviation": home_team.get( + "abbreviation", + home_team.get("name", "???")[:3].upper(), + ), + "name": home_team.get("name", ""), + "displayName": home_team.get("name", ""), + "shortDisplayName": home_team.get("shortName", home_team.get("name", "")), + "logo": "", + }, + "score": str(home.get("score", 0)), + "records": [{"summary": home_record_summary}] + if home_record_summary + else [], + "id": str(home_team.get("id", "")), + }, + { + "homeAway": "away", + "team": { + "id": str(away_team.get("id", "")), + "abbreviation": away_team.get( + "abbreviation", + away_team.get("name", "???")[:3].upper(), + ), + "name": away_team.get("name", ""), + "displayName": away_team.get("name", ""), + "shortDisplayName": away_team.get("shortName", away_team.get("name", "")), + "logo": "", + }, + "score": str(away.get("score", 0)), + "records": [{"summary": away_record_summary}] + if away_record_summary + else [], + "id": str(away_team.get("id", "")), + }, + ], + "status": { + "type": { + "name": status_name, + "state": state, + "detail": detail_text, + "shortDetail": short_detail, + "completed": abstract_state == "Final", + }, + "period": current_inning or 0, + "displayClock": "0:00", + }, + "situation": situation if situation else None, + "odds": [], + "series": {}, + } + ], + } + + return event + + def _fetch_from_mlb_stats_api( + self, dates: List[str], sport_ids: Optional[List[int]] = None + ) -> Optional[Dict]: + """Fetch MiLB games from the MLB Stats API and convert to ESPN format. + + Args: + dates: List of date strings in YYYY-MM-DD format + sport_ids: MiLB sport IDs to fetch (default: self.milb_sport_ids) + + Returns: + ESPN-compatible dict with {"events": [...]} or None on failure + """ + if sport_ids is None: + sport_ids = self.milb_sport_ids + + all_events = [] + sport_id_str = ",".join(str(s) for s in sport_ids) + + for date_str in dates: + url = f"{MLB_STATS_BASE_URL}/schedule?sportId={sport_id_str}&date={date_str}&hydrate=linescore,team" + try: + response = self.session.get( + url, headers=self.headers, timeout=15 + ) + response.raise_for_status() + data = response.json() + + for date_entry in data.get("dates", []): + for game in date_entry.get("games", []): + event = self._convert_stats_game_to_espn_event(game) + all_events.append(event) + + except Exception as e: + self.logger.error( + f"Failed to fetch MiLB data for {date_str}: {e}" + ) + continue + + if all_events: + self.logger.info( + f"Fetched {len(all_events)} MiLB games from MLB Stats API" + ) + else: + self.logger.debug("No MiLB games found from MLB Stats API") + + return {"events": all_events} + + def _fetch_todays_games(self) -> Optional[Dict]: + """Override SportsCore's ESPN-based fetch with MLB Stats API fetch.""" + now = datetime.now(pytz.utc) + today = now.strftime("%Y-%m-%d") + self.logger.debug(f"Fetching today's MiLB games for {today}") + return self._fetch_from_mlb_stats_api([today]) + + def _fetch_milb_api_data(self, use_cache: bool = True) -> Optional[Dict]: + """Fetch MiLB season schedule data using MLB Stats API. + + Returns cached data if available, otherwise fetches from MLB Stats API. + """ + now = datetime.now(pytz.utc) + season_year = now.year + if now.month < 4: + season_year = now.year - 1 + cache_key = f"{self.sport_key}_schedule_{season_year}" + + # Check cache first + if use_cache: + cached_data = self.cache_manager.get(cache_key) + if cached_data: + if isinstance(cached_data, dict) and "events" in cached_data: + self.logger.debug(f"Using cached MiLB schedule for {season_year}") + return cached_data + elif isinstance(cached_data, list): + self.logger.debug( + f"Using cached MiLB schedule for {season_year} (legacy format)" + ) + return {"events": cached_data} + else: + self.logger.warning( + f"Invalid cached data format for {season_year}: {type(cached_data)}" + ) + self.cache_manager.clear_cache(cache_key) + + # MiLB season: April - September + current_month = now.month + in_season = 4 <= current_month <= 9 + + if not in_season: + self.logger.debug( + "MiLB is currently in offseason (October-March). No games expected." + ) + return {"events": []} + + # Fetch recent + upcoming games (7 days back, 7 days ahead) + dates = [ + (now + timedelta(days=d)).strftime("%Y-%m-%d") + for d in range(-7, 8) + ] + + data = self._fetch_from_mlb_stats_api(dates) + + # Cache the result + if data and data.get("events"): + self.cache_manager.set(cache_key, data) + self.logger.info( + f"Cached {len(data['events'])} MiLB events for {season_year}" + ) + + return data + + def _fetch_data(self) -> Optional[Dict]: + """Fetch data using appropriate method based on manager type.""" + if isinstance(self, MiLBLiveManager): + # Live games should fetch only current games + return self._fetch_todays_games() + else: + # Recent and Upcoming managers should use cached schedule data + return self._fetch_milb_api_data(use_cache=True) + + +class MiLBLiveManager(BaseMiLBManager, BaseballLive): + """Manager for live MiLB games.""" + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("MiLBLiveManager") + + if self.test_mode: + # More detailed test game for MiLB + self.current_game = { + "id": "testMiLB001", + "home_abbr": "DUR", + "home_id": "dur001", + "away_abbr": "NOR", + "away_id": "nor001", + "home_score": "5", + "away_score": "3", + "inning": 6, + "inning_half": "bottom", + "balls": 2, + "strikes": 1, + "outs": 1, + "bases_occupied": [True, False, True], + "home_logo_path": Path(self.logo_dir, "DUR.png"), + "away_logo_path": Path(self.logo_dir, "NOR.png"), + "is_live": True, + "is_final": False, + "is_upcoming": False, + "is_halftime": False, + "home_logo_url": "", + "away_logo_url": "", + "series_summary": "", + "status_text": "Bot 6", + } + self.live_games = [self.current_game] + self.logger.info("Initialized MiLBLiveManager with test game: NOR vs DUR") + else: + self.logger.info("Initialized MiLBLiveManager in live mode") + + +class MiLBRecentManager(BaseMiLBManager, BaseballRecent): + """Manager for recently completed MiLB games.""" + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("MiLBRecentManager") + self.logger.info( + f"Initialized MiLBRecentManager with {len(self.favorite_teams)} favorite teams" + ) + + +class MiLBUpcomingManager(BaseMiLBManager, SportsUpcoming): + """Manager for upcoming MiLB games.""" + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("MiLBUpcomingManager") + self.logger.info( + f"Initialized MiLBUpcomingManager with {len(self.favorite_teams)} favorite teams" + ) diff --git a/plugins/baseball-scoreboard/mlb_managers.py b/plugins/baseball-scoreboard/mlb_managers.py new file mode 100644 index 0000000..fe30312 --- /dev/null +++ b/plugins/baseball-scoreboard/mlb_managers.py @@ -0,0 +1,227 @@ +import logging +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional + +import pytz + +from baseball import Baseball, BaseballLive, BaseballRecent +from sports import SportsUpcoming + +# Constants +ESPN_MLB_SCOREBOARD_URL = ( + "https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard" +) + + +class BaseMLBManager(Baseball): + """Base class for MLB managers with common functionality.""" + + # Class variables for warning tracking + _no_data_warning_logged = False + _last_warning_time = 0 + _warning_cooldown = 60 # Only log warnings once per minute + _shared_data = None + _last_shared_update = 0 + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): + self.logger = logging.getLogger("MLB") + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="mlb", + ) + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("mlb_recent", False) + self.upcoming_enabled = display_modes.get("mlb_upcoming", False) + self.live_enabled = display_modes.get("mlb_live", False) + + self.logger.info( + f"Initialized MLB manager with display dimensions: {self.display_width}x{self.display_height}" + ) + self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info( + f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}" + ) + self.league = "mlb" + + def _fetch_mlb_api_data(self, use_cache: bool = True) -> Optional[Dict]: + """ + Fetches the full season schedule for MLB using background threading. + Returns cached data immediately if available, otherwise starts background fetch. + """ + now = datetime.now(pytz.utc) + season_year = now.year + # MLB season runs March to November; if before March, use previous year + if now.month < 3: + season_year = now.year - 1 + datestring = f"{season_year}0301-{season_year}1101" + cache_key = f"{self.sport_key}_schedule_{season_year}" + + # Check cache first + if use_cache: + cached_data = self.cache_manager.get(cache_key) + if cached_data: + # Validate cached data structure + if isinstance(cached_data, dict) and "events" in cached_data: + self.logger.info(f"Using cached schedule for {season_year}") + return cached_data + elif isinstance(cached_data, list): + # Handle old cache format (list of events) + self.logger.info( + f"Using cached schedule for {season_year} (legacy format)" + ) + return {"events": cached_data} + else: + self.logger.warning( + f"Invalid cached data format for {season_year}: {type(cached_data)}" + ) + # Clear invalid cache + self.cache_manager.clear_cache(cache_key) + + # Start background fetch if service is available + if self.background_service and self.background_enabled: + self.logger.info( + f"Starting background fetch for {season_year} season schedule..." + ) + + def fetch_callback(result): + """Callback when background fetch completes.""" + if result.success: + self.logger.info( + f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events" + ) + else: + self.logger.error( + f"Background fetch failed for {season_year}: {result.error}" + ) + + # Clean up request tracking + if season_year in self.background_fetch_requests: + del self.background_fetch_requests[season_year] + + # Get background service configuration + background_config = self.mode_config.get("background_service", {}) + timeout = background_config.get("request_timeout", 30) + max_retries = background_config.get("max_retries", 3) + priority = background_config.get("priority", 2) + + # Submit background fetch request + request_id = self.background_service.submit_fetch_request( + sport="mlb", + year=season_year, + url=ESPN_MLB_SCOREBOARD_URL, + cache_key=cache_key, + params={"dates": datestring, "limit": 1000}, + headers=self.headers, + timeout=timeout, + max_retries=max_retries, + priority=priority, + callback=fetch_callback, + ) + + # Track the request + self.background_fetch_requests[season_year] = request_id + + # For immediate response, try to get partial data + partial_data = self._get_weeks_data() + if partial_data: + return partial_data + else: + # Fallback to synchronous fetch if background service not available + self.logger.warning( + "Background service not available, using synchronous fetch" + ) + try: + response = self.session.get( + ESPN_MLB_SCOREBOARD_URL, + params={"dates": datestring, "limit": 1000}, + headers=self.headers, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + # Cache the data + self.cache_manager.set(cache_key, data) + self.logger.info(f"Synchronously fetched {season_year} season schedule") + return data + + except Exception as e: + self.logger.error(f"Failed to fetch {season_year} season schedule: {e}") + return None + + def _fetch_data(self) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, MLBLiveManager): + # Live games should fetch only current games, not entire season + return self._fetch_todays_games() + else: + # Recent and Upcoming managers should use cached season data + return self._fetch_mlb_api_data(use_cache=True) + + +class MLBLiveManager(BaseMLBManager, BaseballLive): + """Manager for live MLB games.""" + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("MLBLiveManager") + + if self.test_mode: + # Detailed test game for MLB with baseball-specific fields + self.current_game = { + "id": "test001", + "home_abbr": "NYY", + "home_id": "123", + "away_abbr": "BOS", + "away_id": "456", + "home_score": "4", + "away_score": "3", + "inning": 7, + "inning_half": "top", + "balls": 2, + "strikes": 1, + "outs": 1, + "bases_occupied": [True, False, True], + "home_logo_path": Path(self.logo_dir, "NYY.png"), + "away_logo_path": Path(self.logo_dir, "BOS.png"), + "is_live": True, + "is_final": False, + "is_upcoming": False, + "is_halftime": False, + "home_logo_url": "", + "away_logo_url": "", + "series_summary": "", + "status_text": "Top 7th", + } + self.live_games = [self.current_game] + self.logger.info("Initialized MLBLiveManager with test game: BOS @ NYY") + else: + self.logger.info("Initialized MLBLiveManager in live mode") + + +class MLBRecentManager(BaseMLBManager, BaseballRecent): + """Manager for recently completed MLB games.""" + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("MLBRecentManager") + self.logger.info( + f"Initialized MLBRecentManager with {len(self.favorite_teams)} favorite teams" + ) + + +class MLBUpcomingManager(BaseMLBManager, SportsUpcoming): + """Manager for upcoming MLB games.""" + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("MLBUpcomingManager") + self.logger.info( + f"Initialized MLBUpcomingManager with {len(self.favorite_teams)} favorite teams" + ) diff --git a/plugins/baseball-scoreboard/ncaa_baseball_managers.py b/plugins/baseball-scoreboard/ncaa_baseball_managers.py new file mode 100644 index 0000000..52a0458 --- /dev/null +++ b/plugins/baseball-scoreboard/ncaa_baseball_managers.py @@ -0,0 +1,236 @@ +import logging +from typing import Dict, Any, Optional +from datetime import datetime +import pytz +from baseball import Baseball, BaseballLive, BaseballRecent +from sports import SportsUpcoming +from pathlib import Path + +# Constants +ESPN_NCAA_BASEBALL_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/scoreboard" + + +class BaseNCAABaseballManager(Baseball): + """Base class for NCAA Baseball managers with common functionality.""" + + # Class variables for warning tracking + _no_data_warning_logged = False + _last_warning_time = 0 + _warning_cooldown = 60 # Only log warnings once per minute + _shared_data = None + _last_shared_update = 0 + _processed_games_cache = {} # Cache for processed game data + _processed_games_timestamp = 0 + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): + self.logger = logging.getLogger("NCAABaseball") + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="ncaa_baseball", + ) + + # Configuration is already set in base class + # self.logo_dir and self.update_interval are already configured + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("ncaa_baseball_recent", False) + self.upcoming_enabled = display_modes.get("ncaa_baseball_upcoming", False) + self.live_enabled = display_modes.get("ncaa_baseball_live", False) + self.league = "college-baseball" + + self.logger.info( + f"Initialized NCAA Baseball manager with display dimensions: {self.display_width}x{self.display_height}" + ) + self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info( + f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}" + ) + + def _fetch_ncaa_baseball_api_data(self, use_cache: bool = True) -> Optional[Dict]: + """ + Fetches the full season schedule for NCAA Baseball using date range approach to ensure + we get all games, then caches the complete dataset. + + This method now uses background threading to prevent blocking the display. + """ + now = datetime.now(pytz.utc) + season_year = now.year + if now.month < 2: + season_year = now.year - 1 + datestring = f"{season_year}0201-{season_year}0701" + cache_key = f"ncaa_baseball_schedule_{season_year}" + + if use_cache: + cached_data = self.cache_manager.get(cache_key) + if cached_data: + # Validate cached data structure + if isinstance(cached_data, dict) and "events" in cached_data: + self.logger.info(f"Using cached schedule for {season_year}") + return cached_data + elif isinstance(cached_data, list): + # Handle old cache format (list of events) + self.logger.info( + f"Using cached schedule for {season_year} (legacy format)" + ) + return {"events": cached_data} + else: + self.logger.warning( + f"Invalid cached data format for {season_year}: {type(cached_data)}" + ) + # Clear invalid cache + self.cache_manager.clear_cache(cache_key) + + self.logger.info( + f"Fetching full {season_year} season schedule from ESPN API..." + ) + + # Get background service configuration + background_config = self.mode_config.get("background_service", {}) + timeout = background_config.get("request_timeout", 30) + max_retries = background_config.get("max_retries", 3) + priority = background_config.get("priority", 2) + + # Start background fetch if service is available + if self.background_service and self.background_enabled: + self.logger.info( + f"Starting background fetch for {season_year} season schedule..." + ) + + def fetch_callback(result): + """Callback when background fetch completes.""" + if result.success: + self.logger.info( + f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events" + ) + else: + self.logger.error( + f"Background fetch failed for {season_year}: {result.error}" + ) + + # Clean up request tracking + if season_year in self.background_fetch_requests: + del self.background_fetch_requests[season_year] + + # Submit background fetch request + request_id = self.background_service.submit_fetch_request( + sport="ncaa_baseball", + year=season_year, + url=ESPN_NCAA_BASEBALL_SCOREBOARD_URL, + cache_key=cache_key, + params={"dates": datestring, "limit": 1000}, + headers=self.headers, + timeout=timeout, + max_retries=max_retries, + priority=priority, + callback=fetch_callback, + ) + + # Track the request + self.background_fetch_requests[season_year] = request_id + + # For immediate response, try to get partial data + partial_data = self._get_weeks_data() + if partial_data: + return partial_data + else: + # Fallback to synchronous fetch if background service not available + self.logger.warning( + "Background service not available, using synchronous fetch" + ) + try: + response = self.session.get( + ESPN_NCAA_BASEBALL_SCOREBOARD_URL, + params={"dates": datestring, "limit": 1000}, + headers=self.headers, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + # Cache the data + self.cache_manager.set(cache_key, data) + self.logger.info(f"Synchronously fetched {season_year} season schedule") + return data + + except Exception as e: + self.logger.error(f"Failed to fetch {season_year} season schedule: {e}") + return None + + def _fetch_data(self) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, NCAABaseballLiveManager): + return self._fetch_todays_games() + else: + return self._fetch_ncaa_baseball_api_data(use_cache=True) + + +class NCAABaseballLiveManager(BaseNCAABaseballManager, BaseballLive): + """Manager for live NCAA Baseball games.""" + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): + super().__init__( + config=config, display_manager=display_manager, cache_manager=cache_manager + ) + self.logger = logging.getLogger("NCAABaseballLiveManager") + + if self.test_mode: + # More detailed test game for NCAA Baseball + self.current_game = { + "id": "testNCAABB001", + "home_id": "2633", + "away_id": "2579", + "home_abbr": "LSU", + "away_abbr": "MISS", + "home_score": "5", + "away_score": "3", + "inning": 7, + "inning_half": "bottom", + "balls": 2, + "strikes": 1, + "outs": 1, + "bases_occupied": [True, False, True], + "home_logo_path": Path(self.logo_dir, "LSU.png"), + "away_logo_path": Path(self.logo_dir, "MISS.png"), + "is_live": True, + "is_final": False, + "is_upcoming": False, + "is_halftime": False, + "home_logo_url": "", + "away_logo_url": "", + "status_text": "Bot 7th", + "series_summary": "", + } + self.live_games = [self.current_game] + self.logger.info( + "Initialized NCAABaseballLiveManager with test game: MISS vs LSU" + ) + else: + self.logger.info( + "Initialized NCAABaseballLiveManager in live mode" + ) + + +class NCAABaseballRecentManager(BaseNCAABaseballManager, BaseballRecent): + """Manager for recently completed NCAA Baseball games.""" + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("NCAABaseballRecentManager") + self.logger.info( + f"Initialized NCAABaseballRecentManager with {len(self.favorite_teams)} favorite teams" + ) + + +class NCAABaseballUpcomingManager(BaseNCAABaseballManager, SportsUpcoming): + """Manager for upcoming NCAA Baseball games.""" + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("NCAABaseballUpcomingManager") + self.logger.info( + f"Initialized NCAABaseballUpcomingManager with {len(self.favorite_teams)} favorite teams" + ) diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py new file mode 100644 index 0000000..3c27b8a --- /dev/null +++ b/plugins/baseball-scoreboard/sports.py @@ -0,0 +1,2485 @@ +import logging +import os +import threading +import time +from abc import ABC, abstractmethod +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +import pytz +import requests +from PIL import Image, ImageDraw, ImageFont +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +# Import simplified dependencies for plugin use +from dynamic_team_resolver import DynamicTeamResolver +from logo_downloader import LogoDownloader, download_missing_logo +from base_odds_manager import BaseOddsManager +from data_sources import ESPNDataSource + + +class SportsCore(ABC): + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + self.logger = logger + self.config = config + self.cache_manager = cache_manager + self.config_manager = getattr(cache_manager, "config_manager", None) + # Initialize odds manager + self.odds_manager = BaseOddsManager(self.cache_manager, self.config_manager) + self.display_manager = display_manager + # Get display dimensions from matrix (same as base SportsCore class) + # This ensures proper scaling for different display sizes + if 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 + else: + # 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) + + self.sport_key = sport_key + self.sport = None + self.league = None + + # Initialize new architecture components (will be overridden by sport-specific classes) + self.sport_config = None + # Initialize data source + self.data_source = ESPNDataSource(logger) + self.mode_config = config.get( + f"{sport_key}_scoreboard", {} + ) # Changed config key + self.is_enabled: bool = self.mode_config.get("enabled", False) + self.show_odds: bool = self.mode_config.get("show_odds", False) + # Use LogoDownloader to get the correct default logo directory for this sport + from src.logo_downloader import LogoDownloader + default_logo_dir = Path(LogoDownloader().get_logo_directory(sport_key)) + self.logo_dir = default_logo_dir + self.update_interval: int = self.mode_config.get("update_interval_seconds", 60) + self.show_records: bool = self.mode_config.get("show_records", False) + self.show_ranking: bool = self.mode_config.get("show_ranking", False) + # Number of games to show (instead of time-based windows) + self.recent_games_to_show: int = self.mode_config.get( + "recent_games_to_show", 5 + ) # Show last 5 games + self.upcoming_games_to_show: int = self.mode_config.get( + "upcoming_games_to_show", 10 + ) # Show next 10 games + filtering_config = self.mode_config.get("filtering", {}) + self.show_favorite_teams_only: bool = self.mode_config.get( + "show_favorite_teams_only", + filtering_config.get("show_favorite_teams_only", False), + ) + self.show_all_live: bool = self.mode_config.get( + "show_all_live", + filtering_config.get("show_all_live", False), + ) + + self.session = requests.Session() + retry_strategy = Retry( + total=5, # increased number of retries + backoff_factor=1, # increased backoff factor + # added 429 to retry list + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["GET", "HEAD", "OPTIONS"], + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("https://", adapter) + self.session.mount("http://", adapter) + + self._logo_cache = {} + + # Set up headers + self.headers = { + "User-Agent": "LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)", + "Accept": "application/json", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + } + self.last_update = 0 + self.current_game = None + # Thread safety lock for shared game state + self._games_lock = threading.RLock() + self.fonts = self._load_fonts() + + # Initialize dynamic team resolver and resolve favorite teams + self.dynamic_resolver = DynamicTeamResolver(cache_manager=cache_manager) + raw_favorite_teams = self.mode_config.get("favorite_teams", []) + self.favorite_teams = self.dynamic_resolver.resolve_teams( + raw_favorite_teams, sport_key + ) + + # Log dynamic team resolution + if raw_favorite_teams != self.favorite_teams: + self.logger.info( + f"Resolved dynamic teams: {raw_favorite_teams} -> {self.favorite_teams}" + ) + else: + self.logger.info(f"Favorite teams: {self.favorite_teams}") + + self.logger.setLevel(logging.INFO) + + # Initialize team rankings cache + self._team_rankings_cache = {} + self._rankings_cache_timestamp = 0 + self._rankings_cache_duration = 3600 # Cache rankings for 1 hour + + # Initialize background data service with optimized settings + # Hardcoded for memory optimization: 1 worker, 30s timeout, 3 retries + try: + from src.background_data_service import get_background_service + + self.background_service = get_background_service( + self.cache_manager, max_workers=1 + ) + self.background_fetch_requests = {} # Track background fetch requests + self.background_enabled = True + self.logger.info( + "Background service enabled with 1 worker (memory optimized)" + ) + except ImportError: + # Fallback if background service is not available + self.background_service = None + self.background_fetch_requests = {} + self.background_enabled = False + self.logger.warning( + "Background service not available - using synchronous fetching" + ) + + def _get_season_schedule_dates(self) -> tuple[str, str]: + return "", "" + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Placeholder draw method - subclasses should override.""" + # This base method will be simple, subclasses provide specifics + try: + img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) + draw = ImageDraw.Draw(img) + status = game.get("status_text", "N/A") + self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) + self.display_manager.image.paste(img, (0, 0)) + # Don't call update_display here, let subclasses handle it after drawing + except Exception as e: + self.logger.error( + f"Error in base _draw_scorebug_layout: {e}", exc_info=True + ) + + def display(self, force_clear: bool = False) -> bool: + """Render the current game. Returns False when nothing can be shown.""" + if not self.is_enabled: # Check if module is enabled + return False + + if not self.current_game: + # Clear the display so old content doesn't persist + if force_clear: + self.display_manager.clear() + self.display_manager.update_display() + current_time = time.time() + if not hasattr(self, "_last_warning_time"): + self._last_warning_time = 0 + if current_time - getattr(self, "_last_warning_time", 0) > 300: + self.logger.warning( + f"No game data available to display in {self.__class__.__name__}" + ) + setattr(self, "_last_warning_time", current_time) + return False + + try: + self._draw_scorebug_layout(self.current_game, force_clear) + # display_manager.update_display() should be called within subclass draw methods + # or after calling display() in the main loop. Let's keep it out of the base display. + return True + except Exception as e: + self.logger.error( + f"Error during display call in {self.__class__.__name__}: {e}", + exc_info=True, + ) + return False + + def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], default_size: int = 8) -> ImageFont.FreeTypeFont: + """ + Load a custom font from an element configuration dictionary. + + Args: + element_config: Configuration dict for a single element containing 'font' and 'font_size' keys + default_size: Default font size if not specified in config + + Returns: + PIL ImageFont object + """ + # Get font name and size, with defaults + font_name = element_config.get('font', 'PressStart2P-Regular.ttf') + font_size = int(element_config.get('font_size', default_size)) # Ensure integer for PIL + + # Build font path + font_path = os.path.join('assets', 'fonts', font_name) + + # Try to load the font + try: + if os.path.exists(font_path): + # Try loading as TTF first (works for both TTF and some BDF files with PIL) + if font_path.lower().endswith('.ttf'): + font = ImageFont.truetype(font_path, font_size) + self.logger.debug(f"Loaded font: {font_name} at size {font_size}") + return font + elif font_path.lower().endswith('.bdf'): + # PIL's ImageFont.truetype() can sometimes handle BDF files + # If it fails, we'll fall through to the default font + try: + font = ImageFont.truetype(font_path, font_size) + self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}") + return font + except Exception: + self.logger.warning(f"Could not load BDF font {font_name} with PIL, using default") + # Fall through to default + else: + self.logger.warning(f"Unknown font file type: {font_name}, using default") + else: + self.logger.warning(f"Font file not found: {font_path}, using default") + except Exception as e: + self.logger.error(f"Error loading font {font_name}: {e}, using default") + + # Fall back to default font + default_font_path = os.path.join('assets', 'fonts', 'PressStart2P-Regular.ttf') + try: + if os.path.exists(default_font_path): + return ImageFont.truetype(default_font_path, font_size) + else: + self.logger.warning("Default font not found, using PIL default") + return ImageFont.load_default() + except Exception as e: + self.logger.error(f"Error loading default font: {e}") + return ImageFont.load_default() + + def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """ + Get layout offset for a specific element and axis. + + Args: + element: Element name (e.g., 'home_logo', 'score', 'status_text') + axis: 'x_offset' or 'y_offset' (or 'away_x_offset', 'home_x_offset' for records) + default: Default value if not configured (default: 0) + + Returns: + Offset value from config or default (always returns int) + """ + try: + layout_config = self.config.get('customization', {}).get('layout', {}) + element_config = layout_config.get(element, {}) + offset_value = element_config.get(axis, default) + + # Ensure we return an integer (handle float/string from config) + if isinstance(offset_value, (int, float)): + return int(offset_value) + elif isinstance(offset_value, str): + # Try to convert string to int + try: + return int(float(offset_value)) + except (ValueError, TypeError): + self.logger.warning( + f"Invalid layout offset value for {element}.{axis}: '{offset_value}', using default {default}" + ) + return default + else: + return default + except Exception as e: + # Gracefully handle any config access errors + self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}") + return default + + def _load_fonts(self): + """Load fonts used by the scoreboard from config or use defaults.""" + fonts = {} + + # Get customization config, with backward compatibility + customization = self.config.get('customization', {}) + + # Load fonts from config with defaults for backward compatibility + score_config = customization.get('score_text', {}) + period_config = customization.get('period_text', {}) + team_config = customization.get('team_name', {}) + status_config = customization.get('status_text', {}) + detail_config = customization.get('detail_text', {}) + rank_config = customization.get('rank_text', {}) + + try: + fonts["score"] = self._load_custom_font_from_element_config(score_config, default_size=10) + fonts["time"] = self._load_custom_font_from_element_config(period_config, default_size=8) + fonts["team"] = self._load_custom_font_from_element_config(team_config, default_size=8) + fonts["status"] = self._load_custom_font_from_element_config(status_config, default_size=6) + fonts["detail"] = self._load_custom_font_from_element_config(detail_config, default_size=6) + fonts["rank"] = self._load_custom_font_from_element_config(rank_config, default_size=10) + self.logger.info("Successfully loaded fonts from config") + except Exception as e: + self.logger.error(f"Error loading fonts: {e}, using defaults") + # Fallback to hardcoded defaults + 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) + 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_dynamic_odds( + self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int + ) -> None: + """Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team.""" + try: + # Skip odds rendering in test mode or if odds data is invalid + if ( + not odds + or isinstance(odds, dict) + and any( + isinstance(v, type) and hasattr(v, "__call__") + for v in odds.values() + ) + ): + self.logger.debug("Skipping odds rendering - test mode or invalid data") + return + + self.logger.debug(f"Drawing odds with data: {odds}") + + home_team_odds = odds.get("home_team_odds", {}) + away_team_odds = odds.get("away_team_odds", {}) + home_spread = home_team_odds.get("spread_odds") + away_spread = away_team_odds.get("spread_odds") + + # Get top-level spread as fallback + top_level_spread = odds.get("spread") + + # If we have a top-level spread and the individual spreads are None or 0, use the top-level + if top_level_spread is not None: + if home_spread is None or home_spread == 0.0: + home_spread = top_level_spread + if away_spread is None: + away_spread = -top_level_spread + + # Determine which team is favored (has negative spread) + # Add type checking to handle Mock objects in test environment + home_favored = False + away_favored = False + + if home_spread is not None and isinstance(home_spread, (int, float)): + home_favored = home_spread < 0 + if away_spread is not None and isinstance(away_spread, (int, float)): + away_favored = away_spread < 0 + + # Only show the negative spread (favored team) + favored_spread = None + favored_side = None + + if home_favored: + favored_spread = home_spread + favored_side = "home" + self.logger.debug(f"Home team favored with spread: {favored_spread}") + elif away_favored: + favored_spread = away_spread + favored_side = "away" + self.logger.debug(f"Away team favored with spread: {favored_spread}") + else: + self.logger.debug( + "No clear favorite - spreads: home={home_spread}, away={away_spread}" + ) + + # Show the negative spread on the appropriate side + if favored_spread is not None: + spread_text = str(favored_spread) + font = self.fonts["detail"] # Use detail font for odds + + if favored_side == "home": + # Home team is favored, show spread on right side + spread_width = draw.textlength(spread_text, font=font) + spread_x = width - spread_width # Top right + spread_y = 0 + self._draw_text_with_outline( + draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + ) + self.logger.debug( + f"Showing home spread '{spread_text}' on right side" + ) + else: + # Away team is favored, show spread on left side + spread_x = 0 # Top left + spread_y = 0 + self._draw_text_with_outline( + draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + ) + self.logger.debug( + f"Showing away spread '{spread_text}' on left side" + ) + + # Show over/under on the opposite side of the favored team + 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}" + font = self.fonts["detail"] # Use detail font for odds + ou_width = draw.textlength(ou_text, font=font) + + if favored_side == "home": + # Home favored, show O/U on left side (opposite of spread) + ou_x = 0 # Top left + ou_y = 0 + self.logger.debug( + f"Showing O/U '{ou_text}' on left side (home favored)" + ) + elif favored_side == "away": + # Away favored, show O/U on right side (opposite of spread) + ou_x = width - ou_width # Top right + ou_y = 0 + self.logger.debug( + f"Showing O/U '{ou_text}' on right side (away favored)" + ) + else: + # No clear favorite, show O/U in center + ou_x = (width - ou_width) // 2 + ou_y = 0 + self.logger.debug( + f"Showing O/U '{ou_text}' in center (no clear favorite)" + ) + + self._draw_text_with_outline( + draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0) + ) + + except Exception as e: + self.logger.error(f"Error drawing odds: {e}", exc_info=True) + + def _draw_text_with_outline( + self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0) + ): + """Draw text with a black outline for better readability.""" + 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 _load_and_resize_logo( + self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None + ) -> Optional[Image.Image]: + """Load and resize a team logo, with caching and automatic download if missing.""" + self.logger.debug(f"Logo path: {logo_path}") + if team_abbrev in self._logo_cache: + self.logger.debug(f"Using cached logo for {team_abbrev}") + return self._logo_cache[team_abbrev] + + try: + # Try different filename variations first (for cases like TA&M vs TAANDM) + actual_logo_path = None + filename_variations = LogoDownloader.get_logo_filename_variations( + team_abbrev + ) + + for filename in filename_variations: + test_path = logo_path.parent / filename + if test_path.exists(): + actual_logo_path = test_path + self.logger.debug( + f"Found logo at alternative path: {actual_logo_path}" + ) + break + + # If no variation found, try to download missing logo + if not actual_logo_path and not logo_path.exists(): + self.logger.info( + f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download." + ) + + # Try to download the logo from ESPN API (this will create placeholder if download fails) + download_missing_logo( + self.sport_key, team_id, team_abbrev, logo_path, logo_url + ) + actual_logo_path = logo_path + + # Use the original path if no alternative was found + if not actual_logo_path: + actual_logo_path = logo_path + + # Only try to open the logo if the file exists + if os.path.exists(actual_logo_path): + logo = Image.open(actual_logo_path) + else: + self.logger.error( + f"Logo file still doesn't exist at {actual_logo_path} after download attempt" + ) + return None + if logo.mode != "RGBA": + logo = logo.convert("RGBA") + + 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) + self._logo_cache[team_abbrev] = logo + return logo + + except Exception as e: + self.logger.error( + f"Error loading logo for {team_abbrev}: {e}", exc_info=True + ) + return None + + def _fetch_odds(self, game: Dict) -> None: + """Fetch odds for a specific game using async threading to prevent blocking.""" + try: + if not self.show_odds: + return + + # Determine update interval based on game state + is_live = game.get("is_live", False) + update_interval = ( + self.mode_config.get("live_odds_update_interval", 60) + if is_live + else self.mode_config.get("odds_update_interval", 3600) + ) + + # For upcoming games, use async fetch with short timeout to avoid blocking + # For live games, we want odds more urgently, but still use async to prevent blocking + import threading + import queue + + result_queue = queue.Queue() + + def fetch_odds(): + try: + odds_result = self.odds_manager.get_odds( + sport=self.sport, + league=self.league, + event_id=game["id"], + update_interval_seconds=update_interval, + ) + result_queue.put(('success', odds_result)) + except Exception as e: + result_queue.put(('error', e)) + + # Start odds fetch in a separate thread + odds_thread = threading.Thread(target=fetch_odds) + odds_thread.daemon = True + odds_thread.start() + + # Wait for result with timeout (shorter for upcoming games) + timeout = 2.0 if is_live else 1.5 # Live games get slightly longer timeout + try: + result_type, result_data = result_queue.get(timeout=timeout) + if result_type == 'success': + odds_data = result_data + if odds_data: + game["odds"] = odds_data + self.logger.debug( + f"Successfully fetched and attached odds for game {game['id']}" + ) + else: + self.logger.debug(f"No odds data returned for game {game['id']}") + else: + self.logger.debug(f"Odds fetch failed for game {game['id']}: {result_data}") + except queue.Empty: + # Timeout - odds will be fetched on next update if needed + # This prevents blocking the entire update() method + self.logger.debug(f"Odds fetch timed out for game {game['id']} (non-blocking)") + + except Exception as e: + self.logger.error( + f"Error fetching odds for game {game.get('id', 'N/A')}: {e}" + ) + + def _get_timezone(self): + """Get timezone from config, with fallback to cache_manager's config_manager.""" + try: + # First try plugin config + timezone_str = self.config.get("timezone") + # If not in plugin config, try to get from cache_manager's config_manager + if not timezone_str and hasattr(self, 'cache_manager') and hasattr(self.cache_manager, 'config_manager'): + timezone_str = self.cache_manager.config_manager.get_timezone() + # Final fallback to UTC + if not timezone_str: + timezone_str = "UTC" + + self.logger.debug(f"Using timezone: {timezone_str}") + return pytz.timezone(timezone_str) + except pytz.UnknownTimeZoneError: + self.logger.warning(f"Unknown timezone: {timezone_str}, falling back to UTC") + return pytz.utc + + def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: + """Check if we should log a warning based on cooldown period.""" + current_time = time.time() + if current_time - self._last_warning_time > cooldown: + self._last_warning_time = current_time + return True + return False + + def _fetch_team_rankings(self) -> Dict[str, int]: + """Fetch team rankings using the new architecture components.""" + current_time = time.time() + + # Check if we have cached rankings that are still valid + if ( + self._team_rankings_cache + and current_time - self._rankings_cache_timestamp + < self._rankings_cache_duration + ): + return self._team_rankings_cache + + try: + data = self.data_source.fetch_standings(self.sport, self.league) + + rankings = {} + rankings_data = data.get("rankings", []) + + if rankings_data: + # Use the first ranking (usually AP Top 25) + first_ranking = rankings_data[0] + teams = first_ranking.get("ranks", []) + + for team_data in teams: + team_info = team_data.get("team", {}) + team_abbr = team_info.get("abbreviation", "") + current_rank = team_data.get("current", 0) + + if team_abbr and current_rank > 0: + rankings[team_abbr] = current_rank + + # Cache the results + self._team_rankings_cache = rankings + self._rankings_cache_timestamp = current_time + + self.logger.debug(f"Fetched rankings for {len(rankings)} teams") + return rankings + + except Exception as e: + self.logger.error(f"Error fetching team rankings: {e}") + return {} + + @staticmethod + def _extract_team_record(team_data: Dict) -> str: + """Extract the overall record string from a competitor/team object. + + The ESPN scoreboard API uses ``records`` (plural) with a ``summary`` + field, while the team-schedule API uses ``record`` (singular) with a + ``displayValue`` field. This helper handles both formats so that + records display correctly regardless of which API provided the data. + """ + # Scoreboard API format: records[0].summary (e.g. "10-2") + records = team_data.get("records") + if records and isinstance(records, list) and len(records) > 0: + return records[0].get("summary", "") + + # Team-schedule API format: record[0].displayValue (e.g. "7-0") + record = team_data.get("record") + if record and isinstance(record, list) and len(record) > 0: + return record[0].get("displayValue", record[0].get("summary", "")) + + return "" + + def _extract_game_details_common( + self, game_event: Dict + ) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]: + if not game_event: + return None, None, None, None, None + try: + # Safe access to competitions array + competitions = game_event.get("competitions", []) + if not competitions: + self.logger.warning(f"No competitions data for game {game_event.get('id', 'unknown')}") + return None, None, None, None, None + competition = competitions[0] + status = competition.get("status") + if not status: + self.logger.warning(f"No status data for game {game_event.get('id', 'unknown')}") + return None, None, None, None, None + competitors = competition.get("competitors", []) + game_date_str = game_event["date"] + situation = competition.get("situation") + start_time_utc = None + try: + # Parse the datetime string + if game_date_str.endswith('Z'): + game_date_str = game_date_str.replace('Z', '+00:00') + dt = datetime.fromisoformat(game_date_str) + # Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC) + if dt.tzinfo is None: + # If naive, assume it's UTC + start_time_utc = dt.replace(tzinfo=pytz.UTC) + else: + # Convert to pytz.UTC for consistency + start_time_utc = dt.astimezone(pytz.UTC) + except ValueError: + self.logger.warning(f"Could not parse game date: {game_date_str}") + + home_team = next( + (c for c in competitors if c.get("homeAway") == "home"), None + ) + away_team = next( + (c for c in competitors if c.get("homeAway") == "away"), None + ) + + if not home_team or not away_team: + self.logger.warning( + f"Could not find home or away team in event: {game_event.get('id')}" + ) + return None, None, None, None, None + + try: + home_abbr = home_team["team"]["abbreviation"] + except KeyError: + home_abbr = home_team["team"]["name"][:3] + try: + away_abbr = away_team["team"]["abbreviation"] + except KeyError: + away_abbr = away_team["team"]["name"][:3] + + # Check if this is a favorite team game BEFORE doing expensive logging + is_favorite_game = self.favorite_teams and ( + home_abbr in self.favorite_teams or away_abbr in self.favorite_teams + ) + + # Only log debug info for favorite team games + if is_favorite_game: + self.logger.debug( + f"Processing favorite team game: {game_event.get('id')}" + ) + self.logger.debug( + f"Found teams: {away_abbr}@{home_abbr}, Status: {status['type']['name']}, State: {status['type']['state']}" + ) + + game_time, game_date = "", "" + if start_time_utc: + local_time = start_time_utc.astimezone(self._get_timezone()) + game_time = local_time.strftime("%I:%M%p").lstrip("0") + + # Check date format from config + use_short_date_format = self.config.get("display", {}).get( + "use_short_date_format", False + ) + if use_short_date_format: + game_date = local_time.strftime("%-m/%-d") + else: + # Note: display_manager.format_date_with_ordinal will be handled by plugin wrapper + game_date = local_time.strftime("%m/%d") # Simplified for plugin + + home_record = self._extract_team_record(home_team) + away_record = self._extract_team_record(away_team) + + # Don't show "0-0" records - set to blank instead + if home_record in {"0-0", "0-0-0"}: + home_record = "" + if away_record in {"0-0", "0-0-0"}: + away_record = "" + + details = { + "id": game_event.get("id"), + "game_time": game_time, + "game_date": game_date, + "start_time_utc": start_time_utc, + "status_text": status["type"][ + "shortDetail" + ], # e.g., "Final", "7:30 PM", "Q1 12:34" + "is_live": status["type"]["state"] == "in", + "is_final": status["type"]["state"] == "post", + "is_upcoming": ( + status["type"]["state"] == "pre" + or status["type"]["name"].lower() + in ["scheduled", "pre-game", "status_scheduled"] + ), + "is_halftime": status["type"]["state"] == "halftime" + or status["type"]["name"] == "STATUS_HALFTIME", # Added halftime check + "is_period_break": status["type"]["name"] + == "STATUS_END_PERIOD", # Added Period Break check + "home_abbr": home_abbr, + "home_id": home_team["id"], + "home_score": home_team.get("score", "0"), + "home_logo_path": self.logo_dir + / Path(f"{LogoDownloader.normalize_abbreviation(home_abbr)}.png"), + "home_logo_url": home_team["team"].get("logo"), + "home_record": home_record, + "away_record": away_record, + "away_abbr": away_abbr, + "away_id": away_team["id"], + "away_score": away_team.get("score", "0"), + "away_logo_path": self.logo_dir + / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), + "away_logo_url": away_team["team"].get("logo"), + "is_within_window": True, # Whether game is within display window + } + return details, home_team, away_team, status, situation + except Exception as e: + # Log the problematic event structure if possible + self.logger.error( + f"Error extracting game details: {e} from event: {game_event.get('id')}", + exc_info=True, + ) + return None, None, None, None, None + + @abstractmethod + def _extract_game_details(self, game_event: dict) -> dict | None: + details, _, _, _, _ = self._extract_game_details_common(game_event) + return details + + @abstractmethod + def _fetch_data(self) -> Optional[Dict]: + pass + + def _fetch_todays_games(self) -> Optional[Dict]: + """Fetch only today's games for live updates (not entire season).""" + try: + now = datetime.now() + formatted_date = now.strftime("%Y%m%d") + # Fetch todays games only + url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" + self.logger.debug(f"Fetching today's games for {self.sport}/{self.league} on date {formatted_date}") + response = self.session.get( + url, + params={"dates": formatted_date, "limit": 1000}, + headers=self.headers, + timeout=10, + ) + response.raise_for_status() + data = response.json() + events = data.get("events", []) + + self.logger.info( + f"Fetched {len(events)} todays games for {self.sport} - {self.league}" + ) + + # Log status of each game for debugging + if events: + for event in events: + status = event.get("competitions", [{}])[0].get("status", {}) + status_type = status.get("type", {}) + state = status_type.get("state", "unknown") + name = status_type.get("name", "unknown") + self.logger.debug( + f"Event {event.get('id', 'unknown')}: state={state}, name={name}, " + f"shortDetail={status_type.get('shortDetail', 'N/A')}" + ) + + return {"events": events} + except requests.exceptions.RequestException as e: + self.logger.error( + f"API error fetching todays games for {self.sport} - {self.league}: {e}" + ) + return None + + def _get_weeks_data(self) -> Optional[Dict]: + """ + Get partial data for immediate display while background fetch is in progress. + This fetches current/recent games only for quick response. + """ + try: + # Fetch current week and next few days for immediate display + now = datetime.now(pytz.utc) + immediate_events = [] + + start_date = now + timedelta(weeks=-2) + end_date = now + timedelta(weeks=1) + date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" + url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" + response = self.session.get( + url, + params={"dates": date_str, "limit": 1000}, + headers=self.headers, + timeout=10, + ) + response.raise_for_status() + data = response.json() + immediate_events = data.get("events", []) + + if immediate_events: + self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") + return {"events": immediate_events} + + except requests.exceptions.RequestException as e: + self.logger.warning( + f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}" + ) + return None + + def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): + pass + + def cleanup(self): + """Clean up resources when plugin is unloaded.""" + # Close HTTP session + if hasattr(self, 'session') and self.session: + try: + self.session.close() + except Exception as e: + self.logger.warning(f"Error closing session: {e}") + + # Clear caches + if hasattr(self, '_logo_cache'): + self._logo_cache.clear() + + self.logger.info(f"{self.__class__.__name__} cleanup completed") + + +class SportsUpcoming(SportsCore): + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + self.upcoming_games = [] # Store all fetched upcoming games initially + self.games_list = [] # Filtered list for display (favorite teams) + self.current_game_index = 0 + self.last_update = 0 + self.update_interval = self.mode_config.get( + "upcoming_update_interval", 3600 + ) # Check for recent games every hour + self.last_log_time = 0 + self.log_interval = 300 + self.last_warning_time = 0 + self.warning_cooldown = 300 + self.last_game_switch = 0 + self.game_display_duration = self.mode_config.get("upcoming_game_duration", 15) + + def _select_games_for_display( + self, processed_games: List[Dict], favorite_teams: List[str] + ) -> List[Dict]: + """ + Single-pass game selection with proper deduplication and counting. + + When a game involves two favorite teams, it counts toward BOTH teams' limits. + This prevents unexpected game counts from the multi-pass algorithm. + """ + # Sort by start time for consistent priority + sorted_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.max.replace(tzinfo=timezone.utc), + ) + + if not favorite_teams: + # No favorites: return all games (caller will apply limits) + return sorted_games + + selected_games = [] + selected_ids = set() + team_counts = {team: 0 for team in favorite_teams} + + for game in sorted_games: + game_id = game.get("id") + if game_id in selected_ids: + continue + + home = game.get("home_abbr") + away = game.get("away_abbr") + + home_fav = home in favorite_teams + away_fav = away in favorite_teams + + if not home_fav and not away_fav: + continue + + # Check if at least one favorite team still needs games + home_needs = home_fav and team_counts[home] < self.upcoming_games_to_show + away_needs = away_fav and team_counts[away] < self.upcoming_games_to_show + + if home_needs or away_needs: + selected_games.append(game) + selected_ids.add(game_id) + # Count game for ALL favorite teams involved + # This is key: one game counts toward limits of BOTH teams if both are favorites + if home_fav: + team_counts[home] += 1 + if away_fav: + team_counts[away] += 1 + + self.logger.debug( + f"Selected game {away}@{home}: team_counts={team_counts}" + ) + + # Check if all favorites are satisfied + if all(c >= self.upcoming_games_to_show for c in team_counts.values()): + self.logger.debug("All favorite teams satisfied, stopping selection") + break + + self.logger.info( + f"Selected {len(selected_games)} games for {len(favorite_teams)} " + f"favorite teams: {team_counts}" + ) + return selected_games + + def update(self): + """Update upcoming games data.""" + if not self.is_enabled: + return + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + self.last_update = current_time + + # Fetch rankings if enabled + if self.show_ranking: + self._fetch_team_rankings() + + try: + data = self._fetch_data() # Uses shared cache + if not data or "events" not in data: + self.logger.warning( + "No events found in shared data." + ) # Changed log prefix + if not self.games_list: + self.current_game = None + return + + events = data["events"] + # self.logger.info(f"Processing {len(events)} events from shared data.") # Changed log prefix + + processed_games = [] + favorite_games_found = 0 + all_upcoming_games = 0 # Count all upcoming games regardless of favorites + + for event in events: + game = self._extract_game_details(event) + # Count all upcoming games for debugging + if game and game["is_upcoming"]: + all_upcoming_games += 1 + + # Filter criteria: must be upcoming ('pre' state) + if game and game["is_upcoming"]: + # Only fetch odds for games that will be displayed + # If show_favorite_teams_only is True, filter by favorite teams + # But if no favorite teams are configured, show all games (fallback) + if self.show_favorite_teams_only and self.favorite_teams: + if ( + game["home_abbr"] not in self.favorite_teams + and game["away_abbr"] not in self.favorite_teams + ): + continue + processed_games.append(game) + # Count favorite team games for logging + if self.favorite_teams and ( + game["home_abbr"] in self.favorite_teams + or game["away_abbr"] in self.favorite_teams + ): + favorite_games_found += 1 + if self.show_odds: + self._fetch_odds(game) + + # Enhanced logging for debugging + self.logger.info(f"Found {all_upcoming_games} total upcoming games in data") + self.logger.info( + f"Found {len(processed_games)} upcoming games after filtering" + ) + + if processed_games: + for game in processed_games[:3]: # Show first 3 + self.logger.info( + f" {game['away_abbr']}@{game['home_abbr']} - {game['start_time_utc']}" + ) + + if self.favorite_teams and all_upcoming_games > 0: + self.logger.info(f"Favorite teams: {self.favorite_teams}") + self.logger.info( + f"Found {favorite_games_found} favorite team upcoming games" + ) + + # Use single-pass algorithm for game selection + # This properly handles games between two favorite teams (counts for both) + if self.show_favorite_teams_only and self.favorite_teams: + team_games = self._select_games_for_display( + processed_games, self.favorite_teams + ) + else: + # No favorite teams: show N total games sorted by time (schedule view) + team_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.max.replace(tzinfo=timezone.utc), + )[:self.upcoming_games_to_show] + self.logger.info( + f"No favorites configured: showing {len(team_games)} total upcoming games" + ) + + # Log changes or periodically + should_log = ( + current_time - self.last_log_time >= self.log_interval + or len(team_games) != len(self.games_list) + or any( + g1["id"] != g2.get("id") + for g1, g2 in zip(self.games_list, team_games) + ) + or (not self.games_list and team_games) + ) + + # Check if the list of games to display has changed (thread-safe) + with self._games_lock: + new_game_ids = {g["id"] for g in team_games} + current_game_ids = {g["id"] for g in self.games_list} + + if new_game_ids != current_game_ids: + self.logger.info( + f"Found {len(team_games)} upcoming games within window for display." + ) # Changed log prefix + self.games_list = team_games + if ( + not self.current_game + or not self.games_list + or self.current_game["id"] not in new_game_ids + ): + self.current_game_index = 0 + self.current_game = self.games_list[0] if self.games_list else None + self.last_game_switch = current_time + else: + try: + self.current_game_index = next( + i + for i, g in enumerate(self.games_list) + if g["id"] == self.current_game["id"] + ) + self.current_game = self.games_list[self.current_game_index] + except StopIteration: + self.current_game_index = 0 + self.current_game = self.games_list[0] + self.last_game_switch = current_time + + elif self.games_list: + self.current_game = self.games_list[ + self.current_game_index + ] # Update data + + if not self.games_list: + self.logger.info( + "No relevant upcoming games found to display." + ) # Changed log prefix + self.current_game = None + + if should_log and not self.games_list: + # Log favorite teams only if no games are found and logging is needed + self.logger.debug( + f"Favorite teams: {self.favorite_teams}" + ) # Changed log prefix + self.logger.debug( + f"Total upcoming games before filtering: {len(processed_games)}" + ) # Changed log prefix + self.last_log_time = current_time + elif should_log: + self.last_log_time = current_time + + except Exception as e: + self.logger.error( + f"Error updating upcoming games: {e}", exc_info=True + ) # Changed log prefix + # self.current_game = None # Decide if clear on error + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the layout for an upcoming NCAA FB game.""" # Updated docstring + try: + # Clear the display first to ensure full coverage (like weather plugin does) + if force_clear: + self.display_manager.clear() + + # Use display_manager.matrix dimensions directly to ensure full display coverage + display_width = self.display_manager.matrix.width if hasattr(self.display_manager, 'matrix') and self.display_manager.matrix else self.display_width + display_height = self.display_manager.matrix.height if hasattr(self.display_manager, 'matrix') and self.display_manager.matrix else self.display_height + + main_img = Image.new( + "RGBA", (display_width, display_height), (0, 0, 0, 255) + ) + overlay = Image.new( + "RGBA", (display_width, display_height), (0, 0, 0, 0) + ) + draw_overlay = ImageDraw.Draw(overlay) + + home_logo = self._load_and_resize_logo( + game["home_id"], + game["home_abbr"], + game["home_logo_path"], + game.get("home_logo_url"), + ) + away_logo = self._load_and_resize_logo( + game["away_id"], + game["away_abbr"], + game["away_logo_path"], + game.get("away_logo_url"), + ) + + if not home_logo or not away_logo: + self.logger.error( + f"Failed to load logos for game: {game.get('id')}" + ) # Changed log prefix + 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 = main_img.convert("RGB") + self.display_manager.update_display() + return + + center_y = display_height // 2 + + # MLB-style logo positions with layout offsets + home_x = display_width - home_logo.width + 2 + 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 = -2 + 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 Text Elements on Overlay + game_date = game.get("game_date", "") + game_time = game.get("game_time", "") + + # Note: Rankings are now handled in the records/rankings section below + + # "Next Game" at the top (use smaller status font) with layout offsets + status_font = self.fonts["status"] + if display_width > 128: + status_font = self.fonts["time"] + status_text = "Next Game" + status_width = draw_overlay.textlength(status_text, font=status_font) + status_x = (display_width - status_width) // 2 + self._get_layout_offset('status_text', 'x_offset') + status_y = 1 + self._get_layout_offset('status_text', 'y_offset') # Changed from 2 + self._draw_text_with_outline( + draw_overlay, status_text, (status_x, status_y), status_font + ) + + # Date text (centered, below "Next Game") with layout offsets + date_width = draw_overlay.textlength(game_date, font=self.fonts["time"]) + date_x = (display_width - date_width) // 2 + self._get_layout_offset('date', 'x_offset') + # Adjust Y position to stack date and time nicely + date_y = center_y - 7 + self._get_layout_offset('date', 'y_offset') # Raise date slightly + self._draw_text_with_outline( + draw_overlay, game_date, (date_x, date_y), self.fonts["time"] + ) + + # Time text (centered, below Date) with layout offsets + time_width = draw_overlay.textlength(game_time, font=self.fonts["time"]) + time_x = (display_width - time_width) // 2 + self._get_layout_offset('time', 'x_offset') + time_y = date_y + 9 + self._get_layout_offset('time', 'y_offset') # Place time below date + 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._draw_dynamic_odds( + draw_overlay, game["odds"], display_width, display_height + ) + + # Draw records or rankings if enabled + if self.show_records or self.show_ranking: + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + self.logger.debug(f"Loaded 6px record font successfully") + except IOError: + record_font = ImageFont.load_default() + self.logger.warning( + f"Failed to load 6px font, using default font (size: {record_font.size})" + ) + + # Get team abbreviations + away_abbr = game.get("away_abbr", "") + home_abbr = game.get("home_abbr", "") + + 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 + self._get_layout_offset('records', 'y_offset') + self.logger.debug( + f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" + ) + + # Display away team info + if away_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + away_text = "" + elif self.show_ranking: + # Show ranking only if available + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + away_text = "" + elif self.show_records: + # Show record only when rankings are disabled + away_text = game.get("away_record", "") + else: + away_text = "" + + if away_text: + away_record_x = 0 + self._get_layout_offset('records', 'away_x_offset') + self.logger.debug( + f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + away_text, + (away_record_x, record_y), + record_font, + ) + + # Display home team info + if home_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + home_text = "" + elif self.show_ranking: + # Show ranking only if available + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + home_text = "" + elif self.show_records: + # Show record only when rankings are disabled + home_text = game.get("home_record", "") + else: + home_text = "" + + 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._get_layout_offset('records', 'home_x_offset') + self.logger.debug( + f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + 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() # Update display here + + except Exception as e: + self.logger.error( + f"Error displaying upcoming game: {e}", exc_info=True + ) # Changed log prefix + + def display(self, force_clear=False) -> bool: + """Display upcoming games, handling switching.""" + if not self.is_enabled: + return False + + if not self.games_list: + # Clear the display so old content doesn't persist + if force_clear: + self.display_manager.clear() + self.display_manager.update_display() + if self.current_game: + self.current_game = None # Clear state if list empty + current_time = time.time() + # Log warning periodically if no games found + if current_time - self.last_warning_time > self.warning_cooldown: + self.logger.info( + "No upcoming games found for favorite teams to display." + ) # Changed log prefix + self.last_warning_time = current_time + return False # Skip display update + + try: + current_time = time.time() + + # Check if it's time to switch games (protected by lock for thread safety) + with self._games_lock: + if ( + len(self.games_list) > 1 + and current_time - self.last_game_switch >= self.game_display_duration + ): + self.current_game_index = (self.current_game_index + 1) % len( + self.games_list + ) + self.current_game = self.games_list[self.current_game_index] + self.last_game_switch = current_time + force_clear = True # Force redraw on switch + + # Log team switching with sport prefix + if self.current_game: + away_abbr = self.current_game.get("away_abbr", "UNK") + home_abbr = self.current_game.get("home_abbr", "UNK") + sport_prefix = ( + self.sport_key.upper() + if hasattr(self, "sport_key") + else "SPORT" + ) + self.logger.info( + f"[{sport_prefix} Upcoming] Showing {away_abbr} vs {home_abbr}" + ) + else: + self.logger.debug( + f"Switched to game index {self.current_game_index}" + ) + + if self.current_game: + self._draw_scorebug_layout(self.current_game, force_clear) + # update_display() is called within _draw_scorebug_layout for upcoming + + except Exception as e: + self.logger.error( + f"Error in display loop: {e}", exc_info=True + ) # Changed log prefix + return False + + return True + + +class SportsRecent(SportsCore): + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + self.recent_games = [] # Store all fetched recent games initially + self.games_list = [] # Filtered list for display (favorite teams) + self.current_game_index = 0 + self.last_update = 0 + self.update_interval = self.mode_config.get( + "recent_update_interval", 3600 + ) # Check for recent games every hour + self.last_game_switch = 0 + self.game_display_duration = self.mode_config.get("recent_game_duration", 15) + self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 + + def _get_zero_clock_duration(self, game_id: str) -> float: + """Track how long a game has been at 0:00 clock.""" + current_time = time.time() + if game_id not in self._zero_clock_timestamps: + self._zero_clock_timestamps[game_id] = current_time + return 0.0 + return current_time - self._zero_clock_timestamps[game_id] + + def _clear_zero_clock_tracking(self, game_id: str) -> None: + """Clear tracking when game clock moves away from 0:00 or game ends.""" + if game_id in self._zero_clock_timestamps: + del self._zero_clock_timestamps[game_id] + + def _select_recent_games_for_display( + self, processed_games: List[Dict], favorite_teams: List[str] + ) -> List[Dict]: + """ + Single-pass game selection for recent games with proper deduplication. + + When a game involves two favorite teams, it counts toward BOTH teams' limits. + Games are sorted by most recent first. + """ + # Sort by start time, most recent first + sorted_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + + if not favorite_teams: + # No favorites: return all games (caller will apply limits) + return sorted_games + + selected_games = [] + selected_ids = set() + team_counts = {team: 0 for team in favorite_teams} + + for game in sorted_games: + game_id = game.get("id") + if game_id in selected_ids: + continue + + home = game.get("home_abbr") + away = game.get("away_abbr") + + home_fav = home in favorite_teams + away_fav = away in favorite_teams + + if not home_fav and not away_fav: + continue + + # Check if at least one favorite team still needs games + home_needs = home_fav and team_counts[home] < self.recent_games_to_show + away_needs = away_fav and team_counts[away] < self.recent_games_to_show + + if home_needs or away_needs: + selected_games.append(game) + selected_ids.add(game_id) + # Count game for ALL favorite teams involved + if home_fav: + team_counts[home] += 1 + if away_fav: + team_counts[away] += 1 + + self.logger.debug( + f"Selected recent game {away}@{home}: team_counts={team_counts}" + ) + + # Check if all favorites are satisfied + if all(c >= self.recent_games_to_show for c in team_counts.values()): + self.logger.debug("All favorite teams satisfied, stopping selection") + break + + return selected_games + + def update(self): + """Update recent games data.""" + if not self.is_enabled: + return + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + self.last_update = current_time # Update time even if fetch fails + + # Fetch rankings if enabled + if self.show_ranking: + self._fetch_team_rankings() + + try: + data = self._fetch_data() # Uses shared cache + if not data or "events" not in data: + self.logger.warning( + "No events found in shared data." + ) # Changed log prefix + if not self.games_list: + self.current_game = None # Clear display if no games were showing + return + + events = data["events"] + self.logger.info( + f"Processing {len(events)} events from shared data." + ) # Changed log prefix + + # Define date range for "recent" games (last 21 days to capture games from 3 weeks ago) + now = datetime.now(timezone.utc) + recent_cutoff = now - timedelta(days=21) + self.logger.info( + f"Current time: {now}, Recent cutoff: {recent_cutoff} (21 days ago)" + ) + + # Process games and filter for final games, date range & favorite teams + processed_games = [] + for event in events: + game = self._extract_game_details(event) + if not game: + continue + + # Check if game appears finished even if not marked as "post" yet + # This handles cases where API hasn't updated status yet + appears_finished = False + game_id = game.get("id") + if not game.get("is_final", False): + # Check if game appears to be over based on clock/period + clock = game.get("clock", "") + period = game.get("period", 0) + period_text = game.get("status_text", "").lower() + + if period >= 4: + clock_normalized = clock.replace(":", "").strip() + + # Explicit "final" in status text is definitive + if "final" in period_text: + appears_finished = True + self._clear_zero_clock_tracking(game_id) + self.logger.debug( + f"Game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"appears finished (period_text contains 'final')" + ) + elif clock_normalized in ["000", "00", ""] or clock == "0:00" or clock == ":00": + # Clock at 0:00 but no explicit final - use grace period + # This prevents premature transitions during potential OT or reviews + zero_clock_duration = self._get_zero_clock_duration(game_id) + + # Only mark finished after 2 minute grace period (allows OT decisions) + if zero_clock_duration >= 120: + appears_finished = True + self.logger.debug( + f"Game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"appears finished after {zero_clock_duration:.0f}s at 0:00 " + f"(period={period}, clock={clock})" + ) + else: + self.logger.debug( + f"Game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"at 0:00 but only for {zero_clock_duration:.0f}s - waiting for confirmation" + ) + else: + # Clock is not at 0:00, clear any tracking + self._clear_zero_clock_tracking(game_id) + else: + # Game is marked final, clear tracking + self._clear_zero_clock_tracking(game_id) + + # Filter criteria: must be final OR appear finished, AND within recent date range + is_eligible = game.get("is_final", False) or appears_finished + if is_eligible: + game_time = game.get("start_time_utc") + if game_time and game_time >= recent_cutoff: + processed_games.append(game) + # Log when adding games, especially if they appear finished but aren't marked final + final_status = "final" if game.get("is_final") else "appears finished" + self.logger.info( + f"Added {final_status} game to recent list: " + f"{game.get('away_abbr')}@{game.get('home_abbr')} " + f"({game.get('away_score')}-{game.get('home_score')}) " + f"at {game_time.strftime('%Y-%m-%d %H:%M:%S UTC') if game_time else 'unknown time'}" + ) + elif game_time: + self.logger.debug( + f"Game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"is final but outside date range (game_time={game_time}, cutoff={recent_cutoff})" + ) + else: + # Log why game was filtered out (only for favorite teams to reduce noise) + if self.favorite_teams and (game.get("home_abbr") in self.favorite_teams or game.get("away_abbr") in self.favorite_teams): + self.logger.debug( + f"Game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"not included: is_final={game.get('is_final')}, " + f"period={game.get('period')}, clock={game.get('clock')}, " + f"status={game.get('status_text')}" + ) + # Use single-pass algorithm for game selection + # This properly handles games between two favorite teams (counts for both) + if self.show_favorite_teams_only and self.favorite_teams: + team_games = self._select_recent_games_for_display( + processed_games, self.favorite_teams + ) + # Debug: Show which games are selected for display + for i, game in enumerate(team_games): + self.logger.info( + f"Game {i+1} for display: {game['away_abbr']} @ {game['home_abbr']} - {game.get('start_time_utc')} - Score: {game['away_score']}-{game['home_score']}" + ) + else: + # No favorites or show_favorite_teams_only disabled: show N total games sorted by time + team_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + )[:self.recent_games_to_show] + self.logger.info( + f"No favorites configured: showing {len(team_games)} total recent games" + ) + + # Check if the list of games to display has changed (thread-safe) + with self._games_lock: + new_game_ids = {g["id"] for g in team_games} + current_game_ids = {g["id"] for g in self.games_list} + + if new_game_ids != current_game_ids: + self.logger.info( + f"Found {len(team_games)} final games within window for display." + ) # Changed log prefix + self.games_list = team_games + # Reset index if list changed or current game removed + if ( + not self.current_game + or not self.games_list + or self.current_game["id"] not in new_game_ids + ): + self.current_game_index = 0 + self.current_game = self.games_list[0] if self.games_list else None + self.last_game_switch = current_time # Reset switch timer + else: + # Try to maintain position if possible + try: + self.current_game_index = next( + i + for i, g in enumerate(self.games_list) + if g["id"] == self.current_game["id"] + ) + self.current_game = self.games_list[ + self.current_game_index + ] # Update data just in case + except StopIteration: + self.current_game_index = 0 + self.current_game = self.games_list[0] + self.last_game_switch = current_time + + elif self.games_list: + # List content is same, just update data for current game + self.current_game = self.games_list[self.current_game_index] + + if not self.games_list: + self.logger.info( + "No relevant recent games found to display." + ) # Changed log prefix + self.current_game = None # Ensure display clears if no games + + except Exception as e: + self.logger.error( + f"Error updating recent games: {e}", exc_info=True + ) # Changed log prefix + # Don't clear current game on error, keep showing last known state + # self.current_game = None # Decide if we want to clear display on error + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the layout for a recently completed NCAA FB game.""" # Updated docstring + try: + # Clear the display first to ensure full coverage (like weather plugin does) + if force_clear: + self.display_manager.clear() + + # Use display_manager.matrix dimensions directly to ensure full display coverage + display_width = self.display_manager.matrix.width if hasattr(self.display_manager, 'matrix') and self.display_manager.matrix else self.display_width + display_height = self.display_manager.matrix.height if hasattr(self.display_manager, 'matrix') and self.display_manager.matrix else self.display_height + + main_img = Image.new( + "RGBA", (display_width, display_height), (0, 0, 0, 255) + ) + overlay = Image.new( + "RGBA", (display_width, display_height), (0, 0, 0, 0) + ) + draw_overlay = ImageDraw.Draw(overlay) + + home_logo = self._load_and_resize_logo( + game["home_id"], + game["home_abbr"], + game["home_logo_path"], + game.get("home_logo_url"), + ) + away_logo = self._load_and_resize_logo( + game["away_id"], + game["away_abbr"], + game["away_logo_path"], + game.get("away_logo_url"), + ) + + if not home_logo or not away_logo: + self.logger.error( + f"Failed to load logos for game: {game.get('id')}" + ) # Changed log prefix + # Draw placeholder text if logos fail (similar to live) + 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 = main_img.convert("RGB") + self.display_manager.update_display() + return + + center_y = display_height // 2 + + # MLB-style logo positioning (closer to edges) with layout offsets + home_x = display_width - home_logo.width + 2 + 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 = -2 + 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 Text Elements on Overlay + # Note: Rankings are now handled in the records/rankings section below + + # Final Scores (Centered vertically, same position as live) with layout offsets + 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 = (display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') + score_y = (display_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') # Centered vertically, same as live games + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + ) + + # Game date (Bottom of display, one line above bottom edge, centered) with layout offsets + # Use same font as upcoming games (time font) for consistency + game_date = game.get("game_date", "") + if game_date: + date_width = draw_overlay.textlength(game_date, font=self.fonts["time"]) + date_x = (display_width - date_width) // 2 + self._get_layout_offset('date', 'x_offset') + # Position date at bottom of display, one line above the bottom edge + date_y = display_height - 7 + self._get_layout_offset('date', 'y_offset') # One line above bottom edge + self._draw_text_with_outline( + draw_overlay, game_date, (date_x, date_y), self.fonts["time"] + ) + + # "Final" text (Top center) with layout offsets + status_text = game.get( + "period_text", "Final" + ) # Use formatted period text (e.g., "Final/OT") or default "Final" + status_width = draw_overlay.textlength(status_text, font=self.fonts["time"]) + status_x = (display_width - status_width) // 2 + self._get_layout_offset('status_text', 'x_offset') + status_y = 1 + self._get_layout_offset('status_text', 'y_offset') + 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._draw_dynamic_odds( + draw_overlay, game["odds"], display_width, display_height + ) + + # Draw records or rankings if enabled + if self.show_records or self.show_ranking: + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + self.logger.debug(f"Loaded 6px record font successfully") + except IOError: + record_font = ImageFont.load_default() + self.logger.warning( + f"Failed to load 6px font, using default font (size: {record_font.size})" + ) + + # Get team abbreviations + away_abbr = game.get("away_abbr", "") + home_abbr = game.get("home_abbr", "") + + 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 + self.logger.debug( + f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" + ) + + # Display away team info + if away_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + away_text = "" + elif self.show_ranking: + # Show ranking only if available + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + away_text = "" + elif self.show_records: + # Show record only when rankings are disabled + away_text = game.get("away_record", "") + else: + away_text = "" + + if away_text: + away_record_x = 0 + self.logger.debug( + f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + away_text, + (away_record_x, record_y), + record_font, + ) + + # Display home team info + if home_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + home_text = "" + elif self.show_ranking: + # Show ranking only if available + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + home_text = "" + elif self.show_records: + # Show record only when rankings are disabled + home_text = game.get("home_record", "") + else: + home_text = "" + + 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 = display_width - home_record_width + self._get_layout_offset('records', 'home_x_offset') + self.logger.debug( + f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + home_text, + (home_record_x, record_y), + record_font, + ) + + self._custom_scorebug_layout(game, draw_overlay) + # Composite and display + main_img = Image.alpha_composite(main_img, overlay) + main_img = main_img.convert("RGB") + # Assign directly like weather plugin does for full display coverage + self.display_manager.image = main_img + self.display_manager.update_display() # Update display here + + except Exception as e: + self.logger.error( + f"Error displaying recent game: {e}", exc_info=True + ) # Changed log prefix + + def display(self, force_clear=False) -> bool: + """Display recent games, handling switching.""" + if not self.is_enabled or not self.games_list: + # If disabled or no games, clear the display so old content doesn't persist + if force_clear or not self.games_list: + self.display_manager.clear() + self.display_manager.update_display() + if not self.games_list and self.current_game: + self.current_game = None # Clear internal state if list becomes empty + return False + + try: + current_time = time.time() + + # Check if it's time to switch games (protected by lock for thread safety) + with self._games_lock: + if ( + len(self.games_list) > 1 + and current_time - self.last_game_switch >= self.game_display_duration + ): + self.current_game_index = (self.current_game_index + 1) % len( + self.games_list + ) + self.current_game = self.games_list[self.current_game_index] + self.last_game_switch = current_time + force_clear = True # Force redraw on switch + + # Log team switching with sport prefix + if self.current_game: + away_abbr = self.current_game.get("away_abbr", "UNK") + home_abbr = self.current_game.get("home_abbr", "UNK") + sport_prefix = ( + self.sport_key.upper() + if hasattr(self, "sport_key") + else "SPORT" + ) + self.logger.info( + f"[{sport_prefix} Recent] Showing {away_abbr} vs {home_abbr}" + ) + else: + self.logger.debug( + f"Switched to game index {self.current_game_index}" + ) + + if self.current_game: + self._draw_scorebug_layout(self.current_game, force_clear) + # update_display() is called within _draw_scorebug_layout for recent + + except Exception as e: + self.logger.error( + f"Error in display loop: {e}", exc_info=True + ) # Changed log prefix + return False + + return True + + +class SportsLive(SportsCore): + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + self.update_interval = self.mode_config.get("live_update_interval", 15) + self.no_data_interval = 300 + # Log the configured interval for debugging + self.logger.info( + f"SportsLive initialized: live_update_interval={self.update_interval}s, " + f"no_data_interval={self.no_data_interval}s, " + f"mode_config keys={list(self.mode_config.keys())}" + ) + self.last_update = 0 + self.live_games = [] + self.current_game_index = 0 + self.last_game_switch = 0 # Will be set to current_time when games are first loaded + self.game_display_duration = self.mode_config.get("live_game_duration", 20) + self.last_display_update = 0 + self.last_log_time = 0 + self.log_interval = 300 + self.last_count_log_time = 0 # Track when we last logged count data + self.count_log_interval = 5 # Only log count data every 5 seconds + # Initialize test_mode - defaults to False (live mode) + self.test_mode = self.mode_config.get("test_mode", False) + # Track game update timestamps for stale data detection + self.game_update_timestamps = {} # {game_id: {"clock": timestamp, "score": timestamp, "last_seen": timestamp}} + self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default + + def _is_game_really_over(self, game: Dict) -> bool: + """Check if a game appears to be over even if API says it's live.""" + game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" + + # Check if period_text indicates final + period_text = game.get("period_text", "").lower() + if "final" in period_text: + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " + f"returning True - 'final' in period_text='{period_text}'" + ) + return True + + # Check if clock is 0:00 in Q4 or OT + # Safely coerce clock to string to handle None or non-string values + raw_clock = game.get("clock") + if raw_clock is None or not isinstance(raw_clock, str): + clock = "0:00" + else: + clock = raw_clock + period = game.get("period", 0) + # Handle various clock formats: "0:00", ":00", "0", ":40" (stuck at :40) + clock_normalized = clock.replace(":", "").strip() + + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " + f"raw_clock={raw_clock!r}, clock='{clock}', clock_normalized='{clock_normalized}', period={period}, period_text='{period_text}'" + ) + + if period >= 4: + # In Q4 or OT, if clock is 0:00 or appears stuck (like :40), consider it over + # Check for clock at 0:00 - various formats: "0:00", ":00", normalized "000"/"00" + # Note: Clocks like ":40", ":50" are legitimate (under 1 minute remaining) + if clock_normalized == "000" or clock_normalized == "00" or clock == "0:00" or clock == ":00": + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " + f"returning True - clock appears to be 0:00 (clock='{clock}', normalized='{clock_normalized}', period={period})" + ) + return True + + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): returning False" + ) + return False + + def _detect_stale_games(self, games: List[Dict]) -> None: + """Remove games that appear stale or haven't updated.""" + current_time = time.time() + + for game in games[:]: # Copy list to iterate safely + game_id = game.get("id") + if not game_id: + continue + + # Check if game data is stale + timestamps = self.game_update_timestamps.get(game_id, {}) + last_seen = timestamps.get("last_seen", 0) + + if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: + self.logger.warning( + f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(last seen {int(current_time - last_seen)}s ago)" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] + continue + + # Also check if game appears to be over + if self._is_game_really_over(game): + self.logger.debug( + f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] + + def update(self): + """Update live game data and handle game switching.""" + if not self.is_enabled: + return + + # Define current_time and interval before the problematic line (originally line 455) + # Ensure 'import time' is present at the top of the file. + current_time = time.time() + + # Define interval using a pattern similar to NFLLiveManager's update method. + # Uses getattr for robustness, assuming attributes for live_games, test_mode, + # no_data_interval, and update_interval are available on self. + _live_games_attr = self.live_games + _test_mode_attr = getattr( + self, 'test_mode', False + ) # test_mode is often from a base class or config - use getattr for safety + _no_data_interval_attr = ( + self.no_data_interval + ) # Default similar to NFLLiveManager + _update_interval_attr = ( + self.update_interval + ) # Default similar to NFLLiveManager + + # For live managers, always use the configured live_update_interval when checking for updates. + # Only use no_data_interval if we've recently checked and confirmed there are no live games. + # This ensures we check for live games frequently even if the list is temporarily empty. + # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) + time_since_last_update = current_time - self.last_update + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + + if _live_games_attr or _test_mode_attr: + # We have live games or are in test mode, use the configured update interval + interval = _update_interval_attr + elif has_recently_checked: + # We've checked recently and found no live games, use longer interval + interval = _no_data_interval_attr + else: + # First check or haven't checked in a while, use update interval to check for live games + interval = _update_interval_attr + + # Original line from traceback (line 455), now with variables defined: + if current_time - self.last_update >= interval: + self.last_update = current_time + + # Fetch rankings if enabled + if self.show_ranking: + self._fetch_team_rankings() + + # Fetch live game data + data = self._fetch_data() + new_live_games = [] + if not data: + self.logger.debug(f"No data returned from _fetch_data() for {self.sport_key}") + elif "events" not in data: + self.logger.debug(f"Data returned but no 'events' key for {self.sport_key}: {list(data.keys()) if isinstance(data, dict) else type(data)}") + elif data and "events" in data: + total_events = len(data["events"]) + self.logger.debug(f"Fetched {total_events} total events from API for {self.sport_key}") + + live_or_halftime_count = 0 + filtered_out_count = 0 + + for game in data["events"]: + details = self._extract_game_details(game) + if details: + # Log game status for debugging - use INFO level to see what's happening + status_state = game.get("competitions", [{}])[0].get("status", {}).get("type", {}).get("state", "unknown") + status_name = game.get("competitions", [{}])[0].get("status", {}).get("type", {}).get("name", "unknown") + self.logger.info( + f"[{self.sport_key.upper()} Live] Game {details.get('away_abbr', '?')}@{details.get('home_abbr', '?')}: " + f"state={status_state}, name={status_name}, is_live={details.get('is_live')}, " + f"is_halftime={details.get('is_halftime')}, is_final={details.get('is_final')}, " + f"clock={details.get('clock', 'N/A')}, period={details.get('period', 'N/A')}, " + f"status_text={details.get('status_text', 'N/A')}" + ) + + # Filter out final games and games that appear to be over + if details.get("is_final", False): + self.logger.info( + f"[{self.sport_key.upper()} Live] Filtered out final game: {details.get('away_abbr')}@{details.get('home_abbr')} " + f"(is_final={details.get('is_final')}, clock={details.get('clock')}, period={details.get('period')})" + ) + continue + + # Additional validation: check if game appears to be over + if self._is_game_really_over(details): + self.logger.info( + f"[{self.sport_key.upper()} Live] Skipping game that appears final: {details.get('away_abbr')}@{details.get('home_abbr')} " + f"(clock={details.get('clock')}, period={details.get('period')}, period_text={details.get('period_text')})" + ) + continue + + # Check if game should be considered live + # First check explicit flags + is_explicitly_live = details["is_live"] or details["is_halftime"] + + # Also check if game appears to be live based on status even if not explicitly marked + # Some APIs may mark games differently (e.g., "in progress" vs "in") + status_text = details.get("status_text", "").upper() + appears_live_by_status = ( + (status_state == "in" and not details.get("is_final", False)) + or (status_name and "in" in status_name.lower() and "progress" in status_name.lower()) + or (status_text and ("Q1" in status_text or "Q2" in status_text or "Q3" in status_text or "Q4" in status_text or "OT" in status_text)) + or (details.get("clock") and details.get("clock") != "" and details.get("clock") != "0:00" and details.get("clock") != ":00") + ) + + is_actually_live = is_explicitly_live or appears_live_by_status + + if is_actually_live: + if appears_live_by_status and not is_explicitly_live: + # Game appears to be live but wasn't explicitly marked as such - log this + self.logger.warning( + f"[{self.sport_key.upper()} Live] Game {details.get('away_abbr')}@{details.get('home_abbr')} " + f"appears live (state={status_state}, name={status_name}, clock={details.get('clock')}) " + f"but is_live={details.get('is_live')}, is_halftime={details.get('is_halftime')} - treating as live" + ) + live_or_halftime_count += 1 + self.logger.info( + f"[{self.sport_key.upper()} Live] Found live/halftime game: {details.get('away_abbr')}@{details.get('home_abbr')} " + f"(is_live={details.get('is_live')}, is_halftime={details.get('is_halftime')}, " + f"state={status_state}, appears_live_by_status={appears_live_by_status})" + ) + + # Track game timestamps for stale detection + game_id = details.get("id") + if game_id: + current_clock = details.get("clock", "") + current_score = f"{details.get('away_score', '0')}-{details.get('home_score', '0')}" + + if game_id not in self.game_update_timestamps: + self.game_update_timestamps[game_id] = {} + + timestamps = self.game_update_timestamps[game_id] + timestamps["last_seen"] = time.time() + + # Track if clock/score changed + if timestamps.get("last_clock") != current_clock: + timestamps["last_clock"] = current_clock + timestamps["clock_changed_at"] = time.time() + if timestamps.get("last_score") != current_score: + timestamps["last_score"] = current_score + timestamps["score_changed_at"] = time.time() + + # Determine if this game should be included based on filtering settings + # Priority: show_all_live > favorite_teams_only (if favorites exist) > show all + game_str = f"{details.get('away_abbr')}@{details.get('home_abbr')}" + home_abbr = details.get("home_abbr") + away_abbr = details.get("away_abbr") + + if self.show_all_live: + # Always show all live games if show_all_live is enabled + should_include = True + include_reason = "show_all_live=True" + elif not self.show_favorite_teams_only: + # If favorite teams filtering is disabled, show all games + should_include = True + include_reason = "show_favorite_teams_only=False" + elif not self.favorite_teams: + # If favorite teams filtering is enabled but no favorites are configured, + # show all games (same behavior as SportsUpcoming) + should_include = True + include_reason = "favorite_teams is empty" + else: + # Favorite teams filtering is enabled AND favorites are configured + # Only show games involving favorite teams + home_match = home_abbr in self.favorite_teams + away_match = away_abbr in self.favorite_teams + should_include = home_match or away_match + include_reason = ( + f"favorite_teams={self.favorite_teams}, " + f"home_abbr='{home_abbr}' in_favorites={home_match}, " + f"away_abbr='{away_abbr}' in_favorites={away_match}" + ) + + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] {self.sport_key.upper()} filter decision for {game_str}: " + f"should_include={should_include}, reason: {include_reason}" + ) + + if not should_include: + filtered_out_count += 1 + self.logger.info( + f"[{self.sport_key.upper()} Live] Filtered out live game {details.get('away_abbr')}@{details.get('home_abbr')}: " + f"show_all_live={self.show_all_live}, " + f"show_favorite_teams_only={self.show_favorite_teams_only}, " + f"favorite_teams={self.favorite_teams}" + ) + + if should_include: + if self.show_odds: + self._fetch_odds(details) + new_live_games.append(details) + + self.logger.info( + f"[{self.sport_key.upper()} Live] Live game filtering: {total_events} total events, " + f"{live_or_halftime_count} live/halftime, " + f"{filtered_out_count} filtered out, " + f"{len(new_live_games)} included | " + f"show_all_live={self.show_all_live}, " + f"show_favorite_teams_only={self.show_favorite_teams_only}, " + f"favorite_teams={self.favorite_teams if self.favorite_teams else '[] (showing all)'}" + ) + + # Detect and remove stale games + self._detect_stale_games(new_live_games) + + # Log changes or periodically + current_time_for_log = ( + time.time() + ) # Use a consistent time for logging comparison + should_log = ( + current_time_for_log - self.last_log_time >= self.log_interval + or len(new_live_games) != len(self.live_games) + or any( + g1["id"] != g2.get("id") + for g1, g2 in zip(self.live_games, new_live_games) + ) # Check if game IDs changed + or ( + not self.live_games and new_live_games + ) # Log if games appeared + ) + + if should_log: + if new_live_games: + filter_text = ( + "favorite teams" + if self.show_favorite_teams_only or self.show_all_live + else "all teams" + ) + self.logger.info( + f"Found {len(new_live_games)} live/halftime games for {filter_text}." + ) + for ( + game_info + ) in new_live_games: # Renamed game to game_info + self.logger.info( + f" - {game_info['away_abbr']}@{game_info['home_abbr']} ({game_info.get('status_text', 'N/A')})" + ) + else: + filter_text = ( + "favorite teams" + if self.show_favorite_teams_only or self.show_all_live + else "criteria" + ) + self.logger.info( + f"No live/halftime games found for {filter_text}." + ) + self.last_log_time = current_time_for_log + + # Update game list and current game (thread-safe) + with self._games_lock: + if new_live_games: + # Check if the games themselves changed, not just scores/time + new_game_ids = {g["id"] for g in new_live_games} + current_game_ids = {g["id"] for g in self.live_games} + + if new_game_ids != current_game_ids: + self.live_games = sorted( + new_live_games, + key=lambda g: g.get("start_time_utc") + or datetime.now(timezone.utc), + ) # Sort by start time + # Reset index if current game is gone or list is new + if ( + not self.current_game + or self.current_game["id"] not in new_game_ids + ): + self.current_game_index = 0 + self.current_game = ( + self.live_games[0] if self.live_games else None + ) + self.last_game_switch = current_time + else: + # Find current game's new index if it still exists + try: + self.current_game_index = next( + i + for i, g in enumerate(self.live_games) + if g["id"] == self.current_game["id"] + ) + self.current_game = self.live_games[ + self.current_game_index + ] # Update current_game with fresh data + # Fix: Set last_game_switch if it's still 0 (initialized) to prevent immediate switching + if self.last_game_switch == 0: + self.last_game_switch = current_time + except ( + StopIteration + ): # Should not happen if check above passed, but safety first + self.current_game_index = 0 + self.current_game = self.live_games[0] + self.last_game_switch = current_time + + else: + # Just update the data for the existing games + temp_game_dict = {g["id"]: g for g in new_live_games} + self.live_games = [ + temp_game_dict.get(g["id"], g) for g in self.live_games + ] # Update in place + if self.current_game: + self.current_game = temp_game_dict.get( + self.current_game["id"], self.current_game + ) + # Fix: Set last_game_switch if it's still 0 (initialized) to prevent immediate switching + # This handles the case where games were loaded previously but last_game_switch was never set + if self.last_game_switch == 0: + self.last_game_switch = current_time + + # Display update handled by main loop based on interval + + else: + # No live games found + if self.live_games: # Were there games before? + self.logger.info( + "Live games previously showing have ended or are no longer live." + ) # Changed log prefix + self.live_games = [] + self.current_game = None + self.current_game_index = 0 + + else: + # Error fetching data or no events + if self.live_games: # Were there games before? + self.logger.warning( + "Could not fetch update; keeping existing live game data for now." + ) # Changed log prefix + else: + self.logger.warning( + "Could not fetch data and no existing live games." + ) # Changed log prefix + self.current_game = None # Clear current game if fetch fails and no games were active + + # Handle game switching (outside test mode check, thread-safe) + # Fix: Don't check for switching if last_game_switch is still 0 (games haven't been loaded yet) + # This prevents immediate switching when the system has been running for a while before games load + with self._games_lock: + if ( + not self.test_mode + and len(self.live_games) > 1 + and self.last_game_switch > 0 + and (current_time - self.last_game_switch) >= self.game_display_duration + ): + self.current_game_index = (self.current_game_index + 1) % len( + self.live_games + ) + self.current_game = self.live_games[self.current_game_index] + self.last_game_switch = current_time + self.logger.info( + f"Switched live view to: {self.current_game['away_abbr']}@{self.current_game['home_abbr']}" + ) # Changed log prefix + # Force display update via flag or direct call if needed, but usually let main loop handle diff --git a/plugins/basketball-scoreboard/base_odds_manager.py b/plugins/basketball-scoreboard/base_odds_manager.py index 50ccf17..2dffbf6 100644 --- a/plugins/basketball-scoreboard/base_odds_manager.py +++ b/plugins/basketball-scoreboard/base_odds_manager.py @@ -289,9 +289,9 @@ def clear_cache(self, sport: str = None, league: str = None, event_id: str = Non if sport and league and event_id: # Clear specific event cache_key = f"odds_espn_{sport}_{league}_{event_id}" - self.cache_manager.delete(cache_key) + self.cache_manager.clear_cache(cache_key) self.logger.info(f"Cleared cache for {cache_key}") else: # Clear all odds cache - self.cache_manager.clear() + self.cache_manager.clear_cache() self.logger.info("Cleared all cache") diff --git a/plugins/basketball-scoreboard/manager.py b/plugins/basketball-scoreboard/manager.py index 55b6a57..1fbee1a 100644 --- a/plugins/basketball-scoreboard/manager.py +++ b/plugins/basketball-scoreboard/manager.py @@ -800,13 +800,17 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: if not display_config and hasattr(self.cache_manager, 'config_manager'): display_config = self.cache_manager.config_manager.get_display_config() + # Get customization config from main config (shared across all leagues) + customization_config = self.config.get("customization", {}) + manager_config.update( { "timezone": timezone_str, "display": display_config, + "customization": customization_config, } ) - + self.logger.debug(f"Using timezone: {timezone_str} for {league} managers") return manager_config diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 78627a8..260a87f 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.0.5", + "version": "1.1.0", "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores and schedules", "author": "ChuckBuilds", "category": "sports", @@ -18,6 +18,11 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.1.0", + "ledmatrix_min": "2.0.0", + "released": "2026-02-14" + }, { "version": "1.0.5", "ledmatrix_min": "2.0.0", diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index f6cd2fd..64912e6 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -1649,7 +1649,21 @@ def __init__( "recent_update_interval", 3600 ) # Check for recent games every hour self.last_game_switch = 0 - self.game_display_duration = 15 # Display each recent game for 15 seconds + self.game_display_duration = self.mode_config.get("recent_game_duration", 15) + self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 + + def _get_zero_clock_duration(self, game_id: str) -> float: + """Track how long a game has been at 0:00 clock.""" + current_time = time.time() + if game_id not in self._zero_clock_timestamps: + self._zero_clock_timestamps[game_id] = current_time + return 0.0 + return current_time - self._zero_clock_timestamps[game_id] + + def _clear_zero_clock_tracking(self, game_id: str) -> None: + """Clear tracking when game clock moves away from 0:00 or game ends.""" + if game_id in self._zero_clock_timestamps: + del self._zero_clock_timestamps[game_id] def _select_recent_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] @@ -1754,8 +1768,38 @@ def update(self): processed_games = [] for event in events: game = self._extract_game_details(event) - # Filter criteria: must be final AND within recent date range - if game and game["is_final"]: + if not game: + continue + + # Check if game appears finished even if not marked as "post" yet + game_id = game.get("id") + appears_finished = False + if not game.get("is_final", False): + clock = game.get("clock", "") + period = game.get("period", 0) + period_text = game.get("period_text", "").lower() + + if "final" in period_text: + appears_finished = True + self._clear_zero_clock_tracking(game_id) + elif period >= 4: + clock_normalized = clock.replace(":", "").strip() if isinstance(clock, str) else "" + if clock_normalized in ("000", "00", "") or clock in ("0:00", ":00"): + zero_clock_duration = self._get_zero_clock_duration(game_id) + if zero_clock_duration >= 120: + appears_finished = True + self.logger.debug( + f"Game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"appears finished after {zero_clock_duration:.0f}s at 0:00" + ) + else: + self._clear_zero_clock_tracking(game_id) + else: + self._clear_zero_clock_tracking(game_id) + + # Filter criteria: must be final OR appear finished, AND within recent date range + is_eligible = game.get("is_final", False) or appears_finished + if is_eligible: game_time = game.get("start_time_utc") if game_time and game_time >= recent_cutoff: processed_games.append(game) @@ -2172,6 +2216,80 @@ def __init__( self.count_log_interval = 5 # Only log count data every 5 seconds # Initialize test_mode - defaults to False (live mode) self.test_mode = self.mode_config.get("test_mode", False) + # Track game update timestamps for stale data detection + self.game_update_timestamps = {} + self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default + + def _is_game_really_over(self, game: Dict) -> bool: + """Check if a game appears to be over even if API says it's live. + + Basketball: Games end in Q4 or OT when clock hits 0:00. + """ + game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" + + # Check if period_text indicates final + period_text = game.get("period_text", "").lower() + if "final" in period_text: + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning True - 'final' in period_text='{period_text}'" + ) + return True + + # Check if clock is 0:00 in Q4 or OT (period >= 4) + raw_clock = game.get("clock") + if raw_clock is None or not isinstance(raw_clock, str): + clock = "0:00" + else: + clock = raw_clock + period = game.get("period", 0) + clock_normalized = clock.replace(":", "").strip() + + if period >= 4: + if clock_normalized in ("000", "00", "") or clock in ("0:00", ":00"): + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning True - clock at 0:00 (clock='{clock}', period={period})" + ) + return True + + self.logger.debug( + f"_is_game_really_over({game_str}): returning False" + ) + return False + + def _detect_stale_games(self, games: List[Dict]) -> None: + """Remove games that appear stale or haven't updated.""" + current_time = time.time() + + for game in games[:]: # Copy list to iterate safely + game_id = game.get("id") + if not game_id: + continue + + # Check if game data is stale + timestamps = self.game_update_timestamps.get(game_id, {}) + last_seen = timestamps.get("last_seen", 0) + + if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: + self.logger.warning( + f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(last seen {int(current_time - last_seen)}s ago)" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] + continue + + # Also check if game appears to be over + if self._is_game_really_over(game): + self.logger.debug( + f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] def update(self): """Update live game data and handle game switching.""" @@ -2242,10 +2360,21 @@ def update(self): f"state={status_state}, is_live={details.get('is_live')}, " f"is_halftime={details.get('is_halftime')}, is_final={details.get('is_final')}" ) - + + # Filter out final games and games that appear to be over + if details.get("is_final", False): + continue + + if self._is_game_really_over(details): + self.logger.info( + f"Skipping game that appears final: {details.get('away_abbr')}@{details.get('home_abbr')} " + f"(clock={details.get('clock')}, period={details.get('period')}, period_text={details.get('period_text')})" + ) + continue + if details["is_live"] or details["is_halftime"]: live_or_halftime_count += 1 - + # Filtering logic matching SportsUpcoming: # - If show_all_live = True → show all games # - If show_favorite_teams_only = False → show all games @@ -2279,10 +2408,32 @@ def update(self): ) if should_include: + # Track game timestamps for stale detection + game_id = details.get("id") + if game_id: + current_clock = details.get("clock", "") + current_score = f"{details.get('away_score', '0')}-{details.get('home_score', '0')}" + + if game_id not in self.game_update_timestamps: + self.game_update_timestamps[game_id] = {} + + timestamps = self.game_update_timestamps[game_id] + timestamps["last_seen"] = time.time() + + if timestamps.get("last_clock") != current_clock: + timestamps["last_clock"] = current_clock + timestamps["clock_changed_at"] = time.time() + if timestamps.get("last_score") != current_score: + timestamps["last_score"] = current_score + timestamps["score_changed_at"] = time.time() + if self.show_odds: self._fetch_odds(details) new_live_games.append(details) - + + # Detect and remove stale games + self._detect_stale_games(new_live_games) + self.logger.info( f"Live game filtering: {total_events} total events, " f"{live_or_halftime_count} live/halftime, " diff --git a/plugins/football-scoreboard/base_odds_manager.py b/plugins/football-scoreboard/base_odds_manager.py index 9fc9dc6..849204c 100644 --- a/plugins/football-scoreboard/base_odds_manager.py +++ b/plugins/football-scoreboard/base_odds_manager.py @@ -284,9 +284,9 @@ def clear_cache(self, sport: str = None, league: str = None, event_id: str = Non if sport and league and event_id: # Clear specific event cache_key = f"odds_espn_{sport}_{league}_{event_id}" - self.cache_manager.delete(cache_key) + self.cache_manager.clear_cache(cache_key) self.logger.info(f"Cleared cache for {cache_key}") else: # Clear all odds cache - self.cache_manager.clear() + self.cache_manager.clear_cache() self.logger.info("Cleared all cache") diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index ab37058..9ba3cb1 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "2.0.7", + "version": "2.1.0", "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", "description": "Standalone plugin for live, recent, and upcoming football games across NFL and NCAA Football with real-time scores, down/distance, possession, and game status. Now with organized nested config!", @@ -24,6 +24,11 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "2.1.0", + "ledmatrix_min": "2.0.0", + "released": "2026-02-14" + }, { "version": "2.0.7", "ledmatrix_min": "2.0.0", @@ -205,5 +210,6 @@ "downloads": 0, "verified": true, "screenshot": "", - "config_schema": "config_schema.json" + "config_schema": "config_schema.json", + "entry_point": "manager.py" } diff --git a/plugins/hockey-scoreboard/base_classes.py b/plugins/hockey-scoreboard/base_classes.py index b6fe85b..37fd2a7 100644 --- a/plugins/hockey-scoreboard/base_classes.py +++ b/plugins/hockey-scoreboard/base_classes.py @@ -349,12 +349,14 @@ def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: penalties = "" # Extract shots on goal + home_stats = home_team.get("statistics", []) + away_stats = away_team.get("statistics", []) home_shots = next( - (int(c["displayValue"]) for c in home_team["statistics"] if c.get("name") == "shots"), + (int(c["displayValue"]) for c in home_stats if c.get("name") == "shots"), 0 ) away_shots = next( - (int(c["displayValue"]) for c in away_team["statistics"] if c.get("name") == "shots"), + (int(c["displayValue"]) for c in away_stats if c.get("name") == "shots"), 0 ) diff --git a/plugins/hockey-scoreboard/base_odds_manager.py b/plugins/hockey-scoreboard/base_odds_manager.py index 9fc9dc6..849204c 100644 --- a/plugins/hockey-scoreboard/base_odds_manager.py +++ b/plugins/hockey-scoreboard/base_odds_manager.py @@ -284,9 +284,9 @@ def clear_cache(self, sport: str = None, league: str = None, event_id: str = Non if sport and league and event_id: # Clear specific event cache_key = f"odds_espn_{sport}_{league}_{event_id}" - self.cache_manager.delete(cache_key) + self.cache_manager.clear_cache(cache_key) self.logger.info(f"Cleared cache for {cache_key}") else: # Clear all odds cache - self.cache_manager.clear() + self.cache_manager.clear_cache() self.logger.info("Cleared all cache") diff --git a/plugins/hockey-scoreboard/hockey.py b/plugins/hockey-scoreboard/hockey.py index cc0224d..735226e 100644 --- a/plugins/hockey-scoreboard/hockey.py +++ b/plugins/hockey-scoreboard/hockey.py @@ -37,10 +37,12 @@ def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: status = competition["status"] powerplay = False penalties = "" + home_stats = home_team.get("statistics", []) + away_stats = away_team.get("statistics", []) home_team_saves = next( ( int(c["displayValue"]) - for c in home_team["statistics"] + for c in home_stats if c.get("name") == "saves" ), 0, @@ -48,7 +50,7 @@ def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: home_team_saves_per = next( ( float(c["displayValue"]) - for c in home_team["statistics"] + for c in home_stats if c.get("name") == "savePct" ), 0.0, @@ -56,7 +58,7 @@ def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: away_team_saves = next( ( int(c["displayValue"]) - for c in away_team["statistics"] + for c in away_stats if c.get("name") == "saves" ), 0, @@ -64,7 +66,7 @@ def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: away_team_saves_per = next( ( float(c["displayValue"]) - for c in away_team["statistics"] + for c in away_stats if c.get("name") == "savePct" ), 0.0, diff --git a/plugins/hockey-scoreboard/manager.py b/plugins/hockey-scoreboard/manager.py index 573c36e..f484378 100644 --- a/plugins/hockey-scoreboard/manager.py +++ b/plugins/hockey-scoreboard/manager.py @@ -845,10 +845,14 @@ def resolve_live_duration() -> int: if not display_config and hasattr(self.cache_manager, 'config_manager'): display_config = self.cache_manager.config_manager.get_display_config() + # Get customization config from main config (shared across all leagues) + customization_config = self.config.get("customization", {}) + manager_config.update( { "timezone": timezone_str, "display": display_config, + "customization": customization_config, } ) diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index bdb5561..4a3caf4 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.0.8", + "version": "1.1.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -55,6 +55,11 @@ } ], "versions": [ + { + "version": "1.1.0", + "ledmatrix_min": "2.0.0", + "released": "2026-02-14" + }, { "version": "1.0.8", "ledmatrix_min": "2.0.0", diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 7d37de4..44a0976 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -316,7 +316,7 @@ def _load_fonts(self): fonts = {} # Get customization config, with backward compatibility - customization = self.mode_config.get('customization', {}) + customization = self.config.get('customization', {}) # Load fonts from config with defaults for backward compatibility score_config = customization.get('score_text', {}) @@ -1436,7 +1436,21 @@ def __init__( "recent_update_interval", 3600 ) # Check for recent games every hour self.last_game_switch = 0 - self.game_display_duration = 15 # Display each recent game for 15 seconds + self.game_display_duration = self.mode_config.get("recent_game_duration", 15) + self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 + + def _get_zero_clock_duration(self, game_id: str) -> float: + """Track how long a game has been at 0:00 clock.""" + current_time = time.time() + if game_id not in self._zero_clock_timestamps: + self._zero_clock_timestamps[game_id] = current_time + return 0.0 + return current_time - self._zero_clock_timestamps[game_id] + + def _clear_zero_clock_tracking(self, game_id: str) -> None: + """Clear tracking when game clock moves away from 0:00 or game ends.""" + if game_id in self._zero_clock_timestamps: + del self._zero_clock_timestamps[game_id] def _select_recent_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] @@ -1540,8 +1554,38 @@ def update(self): processed_games = [] for event in events: game = self._extract_game_details(event) - # Filter criteria: must be final AND within recent date range - if game and game["is_final"]: + if not game: + continue + + # Check if game appears finished even if not marked as "post" yet + game_id = game.get("id") + appears_finished = False + if not game.get("is_final", False): + clock = game.get("clock", "") + period = game.get("period", 0) + period_text = game.get("period_text", "").lower() + + if "final" in period_text: + appears_finished = True + self._clear_zero_clock_tracking(game_id) + elif period >= 3: # Hockey: 3 periods (P3 or OT) + clock_normalized = clock.replace(":", "").strip() if isinstance(clock, str) else "" + if clock_normalized in ("000", "00", "") or clock in ("0:00", ":00"): + zero_clock_duration = self._get_zero_clock_duration(game_id) + if zero_clock_duration >= 120: + appears_finished = True + self.logger.debug( + f"Game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"appears finished after {zero_clock_duration:.0f}s at 0:00" + ) + else: + self._clear_zero_clock_tracking(game_id) + else: + self._clear_zero_clock_tracking(game_id) + + # Filter criteria: must be final OR appear finished, AND within recent date range + is_eligible = game.get("is_final", False) or appears_finished + if is_eligible: game_time = game.get("start_time_utc") if game_time and game_time >= recent_cutoff: processed_games.append(game) @@ -1897,6 +1941,80 @@ def __init__( self.count_log_interval = 5 # Only log count data every 5 seconds # Initialize test_mode - defaults to False (live mode) self.test_mode = self.mode_config.get("test_mode", False) + # Track game update timestamps for stale data detection + self.game_update_timestamps = {} + self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default + + def _is_game_really_over(self, game: Dict) -> bool: + """Check if a game appears to be over even if API says it's live. + + Hockey: Games end in P3 or OT when clock hits 0:00 (period >= 3). + """ + game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" + + # Check if period_text indicates final + period_text = game.get("period_text", "").lower() + if "final" in period_text: + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning True - 'final' in period_text='{period_text}'" + ) + return True + + # Check if clock is 0:00 in P3 or OT (period >= 3) + raw_clock = game.get("clock") + if raw_clock is None or not isinstance(raw_clock, str): + clock = "0:00" + else: + clock = raw_clock + period = game.get("period", 0) + clock_normalized = clock.replace(":", "").strip() + + if period >= 3: + if clock_normalized in ("000", "00", "") or clock in ("0:00", ":00"): + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning True - clock at 0:00 (clock='{clock}', period={period})" + ) + return True + + self.logger.debug( + f"_is_game_really_over({game_str}): returning False" + ) + return False + + def _detect_stale_games(self, games: List[Dict]) -> None: + """Remove games that appear stale or haven't updated.""" + current_time = time.time() + + for game in games[:]: # Copy list to iterate safely + game_id = game.get("id") + if not game_id: + continue + + # Check if game data is stale + timestamps = self.game_update_timestamps.get(game_id, {}) + last_seen = timestamps.get("last_seen", 0) + + if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: + self.logger.warning( + f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(last seen {int(current_time - last_seen)}s ago)" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] + continue + + # Also check if game appears to be over + if self._is_game_really_over(game): + self.logger.debug( + f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] def update(self): """Update live game data and handle game switching.""" @@ -1966,9 +2084,23 @@ def update(self): for game in data["events"]: details = self._extract_game_details(game) - if details and (details["is_live"] or details["is_halftime"]): + if details: + # Filter out final games and games that appear to be over + if details.get("is_final", False): + continue + + if self._is_game_really_over(details): + self.logger.info( + f"Skipping game that appears final: {details.get('away_abbr')}@{details.get('home_abbr')} " + f"(clock={details.get('clock')}, period={details.get('period')}, period_text={details.get('period_text')})" + ) + continue + + if not (details["is_live"] or details["is_halftime"]): + continue + live_or_halftime_count += 1 - + # Filtering logic matching SportsUpcoming: # - If show_all_live = True → show all games # - If show_favorite_teams_only = False → show all games @@ -2002,10 +2134,32 @@ def update(self): ) if should_include: + # Track game timestamps for stale detection + game_id = details.get("id") + if game_id: + current_clock = details.get("clock", "") + current_score = f"{details.get('away_score', '0')}-{details.get('home_score', '0')}" + + if game_id not in self.game_update_timestamps: + self.game_update_timestamps[game_id] = {} + + timestamps = self.game_update_timestamps[game_id] + timestamps["last_seen"] = time.time() + + if timestamps.get("last_clock") != current_clock: + timestamps["last_clock"] = current_clock + timestamps["clock_changed_at"] = time.time() + if timestamps.get("last_score") != current_score: + timestamps["last_score"] = current_score + timestamps["score_changed_at"] = time.time() + if self.show_odds: self._fetch_odds(details) new_live_games.append(details) - + + # Detect and remove stale games + self._detect_stale_games(new_live_games) + # Log filtering configuration self.logger.info( f"Live game filtering: {len(data['events'])} total events, " diff --git a/plugins/soccer-scoreboard/base_odds_manager.py b/plugins/soccer-scoreboard/base_odds_manager.py index 286d937..871f0f0 100644 --- a/plugins/soccer-scoreboard/base_odds_manager.py +++ b/plugins/soccer-scoreboard/base_odds_manager.py @@ -112,8 +112,14 @@ def get_odds( cached_data = self.cache_manager.get(cache_key) if cached_data: - self.logger.info(f"Using cached odds from ESPN for {cache_key}") - return cached_data + # Filter out the "no_odds" marker – it should not be returned + # as valid odds data. Treat it as a cache miss so a fresh API + # call is made once the cache entry expires. + if isinstance(cached_data, dict) and cached_data.get("no_odds"): + self.logger.debug(f"Cached no-odds marker for {cache_key}, skipping") + else: + self.logger.info(f"Using cached odds from ESPN for {cache_key}") + return cached_data self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}") @@ -274,10 +280,10 @@ def clear_cache(self, sport: str = None, league: str = None, event_id: str = Non if sport and league and event_id: # Clear specific event cache_key = f"odds_espn_{sport}_{league}_{event_id}" - self.cache_manager.delete(cache_key) + self.cache_manager.clear_cache(cache_key) self.logger.info(f"Cleared cache for {cache_key}") else: # Clear all odds cache - self.cache_manager.clear() + self.cache_manager.clear_cache() self.logger.info("Cleared all cache") diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index 5bb5f0d..be3dc8d 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -164,7 +164,10 @@ }, "live_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -175,7 +178,10 @@ }, "recent_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -186,7 +192,10 @@ }, "upcoming_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -410,6 +419,43 @@ } } } + }, + "mode_durations": { + "type": "object", + "title": "Mode-Level Durations", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "properties": { + "recent_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Recent mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "upcoming_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Upcoming mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "live_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Live mode before rotating to next mode. Default: null (uses dynamic calculation)." + } + } } }, "additionalProperties": false @@ -444,7 +490,10 @@ }, "live_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -455,7 +504,10 @@ }, "recent_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -466,7 +518,10 @@ }, "upcoming_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -690,6 +745,43 @@ } } } + }, + "mode_durations": { + "type": "object", + "title": "Mode-Level Durations", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "properties": { + "recent_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Recent mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "upcoming_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Upcoming mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "live_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Live mode before rotating to next mode. Default: null (uses dynamic calculation)." + } + } } }, "additionalProperties": false @@ -724,7 +816,10 @@ }, "live_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -735,7 +830,10 @@ }, "recent_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -746,7 +844,10 @@ }, "upcoming_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -970,6 +1071,43 @@ } } } + }, + "mode_durations": { + "type": "object", + "title": "Mode-Level Durations", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "properties": { + "recent_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Recent mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "upcoming_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Upcoming mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "live_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Live mode before rotating to next mode. Default: null (uses dynamic calculation)." + } + } } }, "additionalProperties": false @@ -1004,7 +1142,10 @@ }, "live_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -1015,7 +1156,10 @@ }, "recent_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -1026,7 +1170,10 @@ }, "upcoming_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -1250,6 +1397,43 @@ } } } + }, + "mode_durations": { + "type": "object", + "title": "Mode-Level Durations", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "properties": { + "recent_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Recent mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "upcoming_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Upcoming mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "live_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Live mode before rotating to next mode. Default: null (uses dynamic calculation)." + } + } } }, "additionalProperties": false @@ -1284,7 +1468,10 @@ }, "live_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -1295,7 +1482,10 @@ }, "recent_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -1306,7 +1496,10 @@ }, "upcoming_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -1530,6 +1723,43 @@ } } } + }, + "mode_durations": { + "type": "object", + "title": "Mode-Level Durations", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "properties": { + "recent_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Recent mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "upcoming_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Upcoming mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "live_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Live mode before rotating to next mode. Default: null (uses dynamic calculation)." + } + } } }, "additionalProperties": false @@ -1564,7 +1794,10 @@ }, "live_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -1575,7 +1808,10 @@ }, "recent_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -1586,7 +1822,10 @@ }, "upcoming_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -1810,6 +2049,43 @@ } } } + }, + "mode_durations": { + "type": "object", + "title": "Mode-Level Durations", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "properties": { + "recent_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Recent mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "upcoming_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Upcoming mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "live_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Live mode before rotating to next mode. Default: null (uses dynamic calculation)." + } + } } }, "additionalProperties": false @@ -1844,7 +2120,10 @@ }, "live_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -1855,7 +2134,10 @@ }, "recent_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -1866,7 +2148,10 @@ }, "upcoming_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -2090,6 +2375,43 @@ } } } + }, + "mode_durations": { + "type": "object", + "title": "Mode-Level Durations", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "properties": { + "recent_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Recent mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "upcoming_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Upcoming mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "live_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Live mode before rotating to next mode. Default: null (uses dynamic calculation)." + } + } } }, "additionalProperties": false @@ -2124,7 +2446,10 @@ }, "live_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -2135,7 +2460,10 @@ }, "recent_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" }, @@ -2146,7 +2474,10 @@ }, "upcoming_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" } @@ -2370,6 +2701,43 @@ } } } + }, + "mode_durations": { + "type": "object", + "title": "Mode-Level Durations", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", + "properties": { + "recent_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Recent mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "upcoming_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Upcoming mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "live_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Live mode before rotating to next mode. Default: null (uses dynamic calculation)." + } + } } }, "additionalProperties": false @@ -2382,7 +2750,12 @@ "title": "Custom Leagues", "description": "Add custom soccer leagues by their ESPN league code. Common codes: por.1 (Liga Portugal), mex.1 (Liga MX), arg.1 (Argentina), bra.1 (Brazil), ned.1 (Eredivisie), sco.1 (Scottish Premiership), tur.1 (Turkish Süper Lig), bel.1 (Belgian Pro League)", "x-widget": "array-table", - "x-columns": ["name", "league_code", "priority", "enabled"], + "x-columns": [ + "name", + "league_code", + "priority", + "enabled" + ], "minItems": 0, "maxItems": 20, "default": [], @@ -2441,7 +2814,10 @@ }, "live_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for live games" }, @@ -2452,7 +2828,10 @@ }, "recent_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for recent games" }, @@ -2463,7 +2842,10 @@ }, "upcoming_display_mode": { "type": "string", - "enum": ["switch", "scroll"], + "enum": [ + "switch", + "scroll" + ], "default": "switch", "description": "Display mode for upcoming games" } @@ -2629,7 +3011,10 @@ "additionalProperties": false } }, - "required": ["name", "league_code"], + "required": [ + "name", + "league_code" + ], "additionalProperties": false } } @@ -2667,7 +3052,10 @@ "default": 10 } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "period_text": { @@ -2698,7 +3086,10 @@ "default": 8 } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "team_name": { @@ -2729,7 +3120,10 @@ "default": 8 } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "status_text": { @@ -2760,7 +3154,10 @@ "default": 6 } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "detail_text": { @@ -2791,7 +3188,10 @@ "default": 6 } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "rank_text": { @@ -2822,7 +3222,10 @@ "default": 10 } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "layout": { @@ -2955,13 +3358,31 @@ "additionalProperties": false } }, - "x-propertyOrder": ["home_logo", "away_logo", "score", "status_text", "date", "time", "records"], + "x-propertyOrder": [ + "home_logo", + "away_logo", + "score", + "status_text", + "date", + "time", + "records" + ], "additionalProperties": false } }, - "x-propertyOrder": ["score_text", "period_text", "team_name", "status_text", "detail_text", "rank_text", "layout"], + "x-propertyOrder": [ + "score_text", + "period_text", + "team_name", + "status_text", + "detail_text", + "rank_text", + "layout" + ], "additionalProperties": false }, "additionalProperties": false, - "required": ["enabled"] + "required": [ + "enabled" + ] } diff --git a/plugins/soccer-scoreboard/data_sources.py b/plugins/soccer-scoreboard/data_sources.py index e80f399..ab2a102 100644 --- a/plugins/soccer-scoreboard/data_sources.py +++ b/plugins/soccer-scoreboard/data_sources.py @@ -1,7 +1,8 @@ """ -Pluggable Data Source Architecture for Soccer Plugin +Pluggable Data Source Architecture -This module provides ESPN data source for soccer leagues. +This module provides abstract data sources that can be plugged into the sports system +to support different APIs and data providers. """ from abc import ABC, abstractmethod @@ -13,15 +14,15 @@ class DataSource(ABC): """Abstract base class for data sources.""" - + def __init__(self, logger: logging.Logger): self.logger = logger self.session = requests.Session() - + # Configure retry strategy from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry - + retry_strategy = Retry( total=5, backoff_factor=1, @@ -30,22 +31,22 @@ def __init__(self, logger: logging.Logger): adapter = HTTPAdapter(max_retries=retry_strategy) self.session.mount("http://", adapter) self.session.mount("https://", adapter) - + @abstractmethod def fetch_live_games(self, sport: str, league: str) -> List[Dict]: """Fetch live games for a sport/league.""" pass - + @abstractmethod def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: """Fetch schedule for a sport/league within date range.""" pass - + @abstractmethod def fetch_standings(self, sport: str, league: str) -> Dict: """Fetch standings for a sport/league.""" pass - + def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { @@ -55,12 +56,12 @@ def get_headers(self) -> Dict[str, str]: class ESPNDataSource(DataSource): - """ESPN API data source for soccer.""" - + """ESPN API data source.""" + def __init__(self, logger: logging.Logger): super().__init__(logger) self.base_url = "https://site.api.espn.com/apis/site/v2/sports" - + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: """Fetch live games from ESPN API.""" try: @@ -69,71 +70,259 @@ def fetch_live_games(self, sport: str, league: str) -> List[Dict]: url = f"{self.base_url}/{sport}/{league}/scoreboard" response = self.session.get(url, params={"dates": formatted_date, "limit": 1000}, headers=self.get_headers(), timeout=15) response.raise_for_status() - + data = response.json() events = data.get('events', []) - + # Filter for live games - live_events = [event for event in events + live_events = [event for event in events if event.get('competitions', [{}])[0].get('status', {}).get('type', {}).get('state') == 'in'] - + self.logger.debug(f"Fetched {len(live_events)} live games for {sport}/{league}") return live_events - + except Exception as e: self.logger.error(f"Error fetching live games from ESPN: {e}") return [] - + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: """Fetch schedule from ESPN API.""" try: start_date, end_date = date_range url = f"{self.base_url}/{sport}/{league}/scoreboard" - + params = { 'dates': f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}", "limit": 1000 } - + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) response.raise_for_status() - + data = response.json() events = data.get('events', []) - + self.logger.debug(f"Fetched {len(events)} scheduled games for {sport}/{league}") return events - + except Exception as e: self.logger.error(f"Error fetching schedule from ESPN: {e}") return [] - + def fetch_standings(self, sport: str, league: str) -> Dict: """Fetch standings from ESPN API.""" - # Try standings endpoint first + # College sports use rankings endpoint, professional leagues use standings + college_leagues = [ + "mens-college-basketball", + "womens-college-basketball", + "college-football", + ] + + # For college sports, use rankings endpoint directly + if league in college_leagues: + try: + url = f"{self.base_url}/{sport}/{league}/rankings" + response = self.session.get(url, headers=self.get_headers(), timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug(f"Fetched rankings for {sport}/{league}") + return data + except Exception as e: + self.logger.debug(f"Error fetching rankings from ESPN for {sport}/{league}: {e}") + return {} + + # For professional leagues, try standings endpoint first try: url = f"{self.base_url}/{sport}/{league}/standings" response = self.session.get(url, headers=self.get_headers(), timeout=15) response.raise_for_status() - + data = response.json() self.logger.debug(f"Fetched standings for {sport}/{league}") return data except Exception as e: - # If standings doesn't exist, try rankings + # If standings doesn't exist, try rankings as fallback if hasattr(e, 'response') and hasattr(e.response, 'status_code') and e.response.status_code == 404: try: url = f"{self.base_url}/{sport}/{league}/rankings" response = self.session.get(url, headers=self.get_headers(), timeout=15) response.raise_for_status() - + data = response.json() self.logger.debug(f"Fetched rankings for {sport}/{league} (fallback)") return data except Exception: + # Both endpoints failed - standings/rankings may not be available for this sport/league self.logger.debug(f"Standings/rankings not available for {sport}/{league} from ESPN API") return {} else: - self.logger.error(f"Error fetching standings from ESPN for {sport}/{league}: {e}") + # Non-404 error - log at debug level since standings are optional + self.logger.debug(f"Error fetching standings from ESPN for {sport}/{league}: {e}") return {} + +class MLBAPIDataSource(DataSource): + """MLB API data source.""" + + def __init__(self, logger: logging.Logger): + super().__init__(logger) + self.base_url = "https://statsapi.mlb.com/api/v1" + + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: + """Fetch live games from MLB API.""" + try: + url = f"{self.base_url}/schedule" + params = { + 'sportId': 1, # MLB + 'date': datetime.now().strftime('%Y-%m-%d'), + 'hydrate': 'game,team,venue,weather' + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + games = data.get('dates', [{}])[0].get('games', []) + + # Filter for live games + live_games = [game for game in games + if game.get('status', {}).get('abstractGameState') == 'Live'] + + self.logger.debug(f"Fetched {len(live_games)} live games from MLB API") + return live_games + + except Exception as e: + self.logger.error(f"Error fetching live games from MLB API: {e}") + return [] + + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: + """Fetch schedule from MLB API.""" + try: + start_date, end_date = date_range + url = f"{self.base_url}/schedule" + + params = { + 'sportId': 1, # MLB + 'startDate': start_date.strftime('%Y-%m-%d'), + 'endDate': end_date.strftime('%Y-%m-%d'), + 'hydrate': 'game,team,venue' + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + all_games = [] + for date_data in data.get('dates', []): + all_games.extend(date_data.get('games', [])) + + self.logger.debug(f"Fetched {len(all_games)} scheduled games from MLB API") + return all_games + + except Exception as e: + self.logger.error(f"Error fetching schedule from MLB API: {e}") + return [] + + def fetch_standings(self, sport: str, league: str) -> Dict: + """Fetch standings from MLB API.""" + try: + url = f"{self.base_url}/standings" + params = { + 'leagueId': 103, # American League + 'season': datetime.now().year, + 'standingsType': 'regularSeason' + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug(f"Fetched standings from MLB API") + return data + + except Exception as e: + self.logger.error(f"Error fetching standings from MLB API: {e}") + return {} + + +class SoccerAPIDataSource(DataSource): + """Soccer API data source (generic structure).""" + + def __init__(self, logger: logging.Logger, api_key: str = None): + super().__init__(logger) + self.api_key = api_key + self.base_url = "https://api.football-data.org/v4" # Example API + + def get_headers(self) -> Dict[str, str]: + """Get headers with API key for soccer API.""" + headers = super().get_headers() + if self.api_key: + headers['X-Auth-Token'] = self.api_key + return headers + + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: + """Fetch live games from soccer API.""" + try: + # This would need to be adapted based on the specific soccer API + url = f"{self.base_url}/matches" + params = { + 'status': 'LIVE', + 'competition': league + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + matches = data.get('matches', []) + + self.logger.debug(f"Fetched {len(matches)} live games from soccer API") + return matches + + except Exception as e: + self.logger.error(f"Error fetching live games from soccer API: {e}") + return [] + + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: + """Fetch schedule from soccer API.""" + try: + start_date, end_date = date_range + url = f"{self.base_url}/matches" + + params = { + 'competition': league, + 'dateFrom': start_date.strftime('%Y-%m-%d'), + 'dateTo': end_date.strftime('%Y-%m-%d') + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + matches = data.get('matches', []) + + self.logger.debug(f"Fetched {len(matches)} scheduled games from soccer API") + return matches + + except Exception as e: + self.logger.error(f"Error fetching schedule from soccer API: {e}") + return [] + + def fetch_standings(self, sport: str, league: str) -> Dict: + """Fetch standings from soccer API.""" + try: + url = f"{self.base_url}/competitions/{league}/standings" + response = self.session.get(url, headers=self.get_headers(), timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug(f"Fetched standings from soccer API") + return data + + except Exception as e: + self.logger.error(f"Error fetching standings from soccer API: {e}") + return {} + + +# Factory function removed - sport classes now instantiate data sources directly diff --git a/plugins/soccer-scoreboard/manager.py b/plugins/soccer-scoreboard/manager.py index 51567f8..030888f 100644 --- a/plugins/soccer-scoreboard/manager.py +++ b/plugins/soccer-scoreboard/manager.py @@ -405,13 +405,17 @@ def _adapt_config_for_manager(self, league_key: str) -> Dict[str, Any]: if not display_config and hasattr(self.cache_manager, 'config_manager'): display_config = self.cache_manager.config_manager.get_display_config() + # Get customization config from main config (shared across all leagues) + customization_config = self.config.get("customization", {}) + manager_config.update( { "timezone": timezone_str, "display": display_config, + "customization": customization_config, } ) - + self.logger.debug(f"Using timezone: {timezone_str} for {league_key} managers") return manager_config @@ -619,9 +623,13 @@ def _adapt_config_for_custom_league(self, custom_league: Dict[str, Any]) -> Dict if not display_config and hasattr(self.cache_manager, 'config_manager'): display_config = self.cache_manager.config_manager.get_display_config() + # Get customization config from main config (shared across all leagues) + customization_config = self.config.get("customization", {}) + manager_config.update({ "timezone": timezone_str, "display": display_config, + "customization": customization_config, }) return manager_config @@ -1808,6 +1816,160 @@ def get_dynamic_duration_cap(self) -> Optional[float]: # No global fallback - return None return None + def _extract_mode_type(self, display_mode: str) -> Optional[str]: + """Extract mode type (live, recent, upcoming) from display mode string. + + Args: + display_mode: Display mode string (e.g., 'soccer_live', 'soccer_recent') + + Returns: + Mode type string ('live', 'recent', 'upcoming') or None + """ + if display_mode.endswith('_live'): + return 'live' + elif display_mode.endswith('_recent'): + return 'recent' + elif display_mode.endswith('_upcoming'): + return 'upcoming' + return None + + def _get_game_duration(self, league: str, mode_type: str, manager=None) -> float: + """Get game duration for a league and mode type combination. + + Resolves duration using the following hierarchy: + 1. Manager's game_display_duration attribute (if manager provided) + 2. League-specific mode duration from display_durations + 3. Default (15 seconds) + + Args: + league: League key (e.g., 'eng.1', 'esp.1') + mode_type: Mode type ('live', 'recent', or 'upcoming') + manager: Optional manager instance + + Returns: + Game duration in seconds (float) + """ + if manager: + manager_duration = getattr(manager, 'game_display_duration', None) + if manager_duration is not None: + return float(manager_duration) + + leagues_config = self.config.get('leagues', {}) + league_config = leagues_config.get(league, {}) + display_durations = league_config.get("display_durations", {}) + mode_duration = display_durations.get(mode_type) + if mode_duration is not None: + return float(mode_duration) + + return 15.0 + + def _get_mode_duration(self, league: str, mode_type: str) -> Optional[float]: + """Get mode duration from config for a league/mode combination. + + Checks per-league/per-mode settings first, then falls back to None. + Returns None if not configured (uses dynamic calculation). + + Args: + league: League key (e.g., 'eng.1', 'esp.1') + mode_type: Mode type ('live', 'recent', or 'upcoming') + + Returns: + Mode duration in seconds (float) or None if not configured + """ + leagues_config = self.config.get('leagues', {}) + league_config = leagues_config.get(league, {}) + mode_durations = league_config.get("mode_durations", {}) + + mode_duration_key = f"{mode_type}_mode_duration" + if mode_duration_key in mode_durations: + value = mode_durations[mode_duration_key] + if value is not None: + try: + return float(value) + except (TypeError, ValueError): + pass + + return None + + def _get_effective_mode_duration(self, display_mode: str, mode_type: str) -> Optional[float]: + """Get effective mode duration for a display mode. + + Checks per-mode duration settings first, then falls back to dynamic calculation. + + Args: + display_mode: Display mode name (e.g., 'soccer_recent') + mode_type: Mode type ('live', 'recent', or 'upcoming') + + Returns: + Mode duration in seconds (float) or None to use dynamic calculation + """ + if not self._current_display_league: + return None + + mode_duration = self._get_mode_duration(self._current_display_league, mode_type) + if mode_duration is not None: + return mode_duration + + return None + + def get_cycle_duration(self, display_mode: str = None) -> Optional[float]: + """Calculate the expected cycle duration for a display mode. + + Supports mode-level durations and dynamic calculation: + - Mode-level duration: Fixed total time for mode (e.g., recent_mode_duration) + - Dynamic calculation: Total duration = num_games x per_game_duration + - Dynamic duration cap applies to both if enabled + + Args: + display_mode: The display mode (e.g., 'soccer_live', 'soccer_recent') + + Returns: + Total expected duration in seconds, or None if not applicable + """ + if not self.is_enabled or not display_mode: + return None + + mode_type = self._extract_mode_type(display_mode) + if not mode_type: + return None + + # Check for per-mode duration first (fixed total time for mode) + effective_duration = self._get_effective_mode_duration(display_mode, mode_type) + if effective_duration is not None: + # Apply dynamic cap if configured + if self._dynamic_feature_enabled(): + cap = self.get_dynamic_duration_cap() + if cap is not None: + effective_duration = min(effective_duration, cap) + return effective_duration + + # No mode-level duration - use dynamic calculation + # Count games across all enabled leagues for this mode type + total_games = 0 + game_duration = 15.0 + + for league_key, league_data in self._league_registry.items(): + if not league_data.get('enabled', False): + continue + manager = league_data.get('managers', {}).get(mode_type) + if manager: + games = getattr(manager, 'games', []) + total_games += len(games) + game_duration = self._get_game_duration(league_key, mode_type, manager) + + if total_games == 0: + return None + + total_duration = total_games * game_duration + + # Apply dynamic cap if configured + if self._dynamic_feature_enabled(): + cap = self.get_dynamic_duration_cap() + if cap is not None: + total_duration = min(total_duration, cap) + + return total_duration + def _get_manager_for_mode(self, mode_name: str): """Resolve manager instance for a given display mode.""" # Strip "soccer_" prefix and split from right to handle league codes with underscores diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index e821aa0..e0f040e 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "1.1.0", + "version": "1.3.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, and more", "category": "sports", @@ -24,6 +24,11 @@ "soccer_upcoming" ], "versions": [ + { + "released": "2026-02-14", + "version": "1.3.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2025-10-19", "version": "1.0.1", diff --git a/plugins/soccer-scoreboard/scroll_display.py b/plugins/soccer-scoreboard/scroll_display.py index 4273b13..fc1eeb2 100644 --- a/plugins/soccer-scoreboard/scroll_display.py +++ b/plugins/soccer-scoreboard/scroll_display.py @@ -478,3 +478,182 @@ def get_current_game_count(self) -> int: def get_current_leagues(self) -> List[str]: """Get the list of leagues in the current scroll.""" return self._current_leagues.copy() + + def get_scroll_info(self) -> Dict[str, Any]: + """Get current scroll state information.""" + if not self.scroll_helper: + return {"error": "ScrollHelper not available"} + + info = self.scroll_helper.get_scroll_info() + info.update({ + "game_count": len(self._current_games), + "game_type": self._current_game_type, + "leagues": self._current_leagues, + "is_scrolling": self._is_scrolling + }) + return info + + def get_dynamic_duration(self) -> int: + """Get the calculated dynamic duration for this scroll content.""" + if self.scroll_helper: + return self.scroll_helper.get_dynamic_duration() + return 60 # Default fallback + + def clear(self) -> None: + """Clear scroll content and reset state.""" + self.clear_cache() + + +class ScrollDisplayManager: + """ + Manages scroll display instances for different game types. + + This class provides a higher-level interface for the soccer plugin + to manage scroll displays for live, recent, and upcoming games. + """ + + def __init__( + self, + display_manager, + config: Dict[str, Any], + custom_logger: Optional[logging.Logger] = None + ): + """ + Initialize the ScrollDisplayManager. + + Args: + display_manager: Display manager instance + config: Plugin configuration dictionary + custom_logger: Optional custom logger instance + """ + self.display_manager = display_manager + self.config = config + self.logger = custom_logger or logger + + # Determine plugin directory for asset loading + self._plugin_dir = str(Path(__file__).parent) + + # Create scroll displays for each game type + self._scroll_displays: Dict[str, ScrollDisplay] = {} + self._current_game_type: Optional[str] = None + + def get_scroll_display(self, game_type: str) -> ScrollDisplay: + """ + Get or create a scroll display for a game type. + + Args: + game_type: Type of games ('live', 'recent', 'upcoming', 'mixed') + + Returns: + ScrollDisplay instance for the game type + """ + if game_type not in self._scroll_displays: + display_width = self.display_manager.matrix.width + display_height = self.display_manager.matrix.height + self._scroll_displays[game_type] = ScrollDisplay( + self.display_manager, + display_width, + display_height, + self.config, + self._plugin_dir + ) + return self._scroll_displays[game_type] + + def prepare_and_display( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Dict[str, int] = None + ) -> bool: + """ + Prepare content and start displaying scroll. + + Args: + games: List of game dictionaries + game_type: Type of games + leagues: List of leagues + rankings_cache: Optional team rankings cache + + Returns: + True if scroll was started successfully + """ + scroll_display = self.get_scroll_display(game_type) + + success = scroll_display.prepare_scroll_content( + games, game_type, leagues, rankings_cache + ) + + if success: + self._current_game_type = game_type + + return success + + def display_frame(self, game_type: str = None) -> bool: + """ + Display the next frame of the current scroll. + + Args: + game_type: Optional game type (uses current if not specified) + + Returns: + True if a frame was displayed + """ + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return False + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return False + + return scroll_display.display_scroll_frame() + + def is_complete(self, game_type: str = None) -> bool: + """Check if the current scroll is complete.""" + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return True + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return True + + return scroll_display.is_scroll_complete() + + def get_dynamic_duration(self, game_type: str = None) -> int: + """Get the dynamic duration for the current scroll.""" + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return 60 + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return 60 + + return scroll_display.get_dynamic_duration() + + def has_cached_content(self) -> bool: + """ + Check if any scroll display has cached content. + + Returns: + True if any scroll display has a cached image ready for display + """ + for scroll_display in self._scroll_displays.values(): + if hasattr(scroll_display, 'scroll_helper') and scroll_display.scroll_helper: + if scroll_display.scroll_helper.cached_image is not None: + return True + return False + + def clear_all(self) -> None: + """Clear all scroll displays.""" + for scroll_display in self._scroll_displays.values(): + scroll_display.clear() + self._current_game_type = None diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index db13cdb..39654f2 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -1639,7 +1639,21 @@ def __init__( "recent_update_interval", 3600 ) # Check for recent games every hour self.last_game_switch = 0 - self.game_display_duration = 15 # Display each recent game for 15 seconds + self.game_display_duration = self.mode_config.get("recent_game_duration", 15) + self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 + + def _get_zero_clock_duration(self, game_id: str) -> float: + """Track how long a game has been at 0:00 clock.""" + current_time = time.time() + if game_id not in self._zero_clock_timestamps: + self._zero_clock_timestamps[game_id] = current_time + return 0.0 + return current_time - self._zero_clock_timestamps[game_id] + + def _clear_zero_clock_tracking(self, game_id: str) -> None: + """Clear tracking when game clock moves away from 0:00 or game ends.""" + if game_id in self._zero_clock_timestamps: + del self._zero_clock_timestamps[game_id] def _select_recent_games_for_display( self, processed_games: List[Dict], favorite_teams: List[str] @@ -1743,8 +1757,23 @@ def update(self): processed_games = [] for event in events: game = self._extract_game_details(event) - # Filter criteria: must be final AND within recent date range - if game and game["is_final"]: + if not game: + continue + + # Check if game appears finished even if not marked as "post" yet + game_id = game.get("id") + appears_finished = False + if not game.get("is_final", False): + period_text = game.get("period_text", "").lower() + if "final" in period_text: + appears_finished = True + self._clear_zero_clock_tracking(game_id) + else: + self._clear_zero_clock_tracking(game_id) + + # Filter criteria: must be final OR appear finished, AND within recent date range + is_eligible = game.get("is_final", False) or appears_finished + if is_eligible: game_time = game.get("start_time_utc") if game_time and game_time >= recent_cutoff: processed_games.append(game) @@ -2149,6 +2178,66 @@ def __init__( self.count_log_interval = 5 # Only log count data every 5 seconds # Initialize test_mode - defaults to False (live mode) self.test_mode = self.mode_config.get("test_mode", False) + # Track game update timestamps for stale data detection + self.game_update_timestamps = {} + self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default + + def _is_game_really_over(self, game: Dict) -> bool: + """Check if a game appears to be over even if API says it's live. + + Soccer-specific: clock counts UP (e.g., 75', 90+3'), so we check for + 'final' in period_text. The 0:00 clock check used in countdown-clock + sports doesn't apply here. + """ + game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" + + # Check if period_text indicates final + period_text = game.get("period_text", "").lower() + if "final" in period_text: + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning True - 'final' in period_text='{period_text}'" + ) + return True + + self.logger.debug( + f"_is_game_really_over({game_str}): returning False " + f"(period_text='{period_text}', period={game.get('period', 0)})" + ) + return False + + def _detect_stale_games(self, games: List[Dict]) -> None: + """Remove games that appear stale or haven't updated.""" + current_time = time.time() + + for game in games[:]: # Copy list to iterate safely + game_id = game.get("id") + if not game_id: + continue + + # Check if game data is stale + timestamps = self.game_update_timestamps.get(game_id, {}) + last_seen = timestamps.get("last_seen", 0) + + if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: + self.logger.warning( + f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(last seen {int(current_time - last_seen)}s ago)" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] + continue + + # Also check if game appears to be over + if self._is_game_really_over(game): + self.logger.debug( + f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] def update(self): """Update live game data and handle game switching.""" @@ -2219,7 +2308,18 @@ def update(self): f"state={status_state}, is_live={details.get('is_live')}, " f"is_halftime={details.get('is_halftime')}, is_final={details.get('is_final')}" ) - + + # Filter out final games and games that appear to be over + if details.get("is_final", False): + continue + + if self._is_game_really_over(details): + self.logger.info( + f"Skipping game that appears final: {details.get('away_abbr')}@{details.get('home_abbr')} " + f"(clock={details.get('clock')}, period={details.get('period')}, period_text={details.get('period_text')})" + ) + continue + if details["is_live"] or details["is_halftime"]: live_or_halftime_count += 1 @@ -2256,10 +2356,32 @@ def update(self): ) if should_include: + # Track game timestamps for stale detection + game_id = details.get("id") + if game_id: + current_clock = details.get("clock", "") + current_score = f"{details.get('away_score', '0')}-{details.get('home_score', '0')}" + + if game_id not in self.game_update_timestamps: + self.game_update_timestamps[game_id] = {} + + timestamps = self.game_update_timestamps[game_id] + timestamps["last_seen"] = time.time() + + if timestamps.get("last_clock") != current_clock: + timestamps["last_clock"] = current_clock + timestamps["clock_changed_at"] = time.time() + if timestamps.get("last_score") != current_score: + timestamps["last_score"] = current_score + timestamps["score_changed_at"] = time.time() + if self.show_odds: self._fetch_odds(details) new_live_games.append(details) - + + # Detect and remove stale games + self._detect_stale_games(new_live_games) + self.logger.info( f"Live game filtering: {total_events} total events, " f"{live_or_halftime_count} live/halftime, " From f84c9ee94befb9001f2b73743fae36bad8a203b6 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 14 Feb 2026 19:59:44 -0500 Subject: [PATCH 08/15] =?UTF-8?q?fix:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20cache=20TTL,=20finish=20detection,=20state=20leak,?= =?UTF-8?q?=20and=20safety=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pass cache TTL (interval) to cache_manager.set() in all 5 base_odds_manager.py files so odds data expires correctly instead of living forever - Fix baseball base_odds_manager.py deprecated .delete()/.clear() → .clear_cache() - Fix premature finish detection in basketball/hockey/football sports.py: when clock is None or not a string, no longer default to "0:00" which falsely declares games over; instead skip clock-based check entirely - Fix baseball.py display_manager.draw state leak: save/restore with try/finally so BDF text rendering doesn't permanently overwrite the draw object - Guard data_sources.py against empty competitions[] and dates[] lists (IndexError) - Use LogoDownloader session with retry logic in logo_downloader.py instead of bare requests.get for logo downloads - Change MiLB Stats API URL from HTTP to HTTPS in milb_managers.py - Fix soccer manager.py get_cycle_duration: accumulate per-league (games * duration) instead of using a single overwritten game_duration variable Co-Authored-By: Claude Opus 4.6 --- .../baseball-scoreboard/base_odds_manager.py | 8 +-- plugins/baseball-scoreboard/baseball.py | 53 ++++++++++--------- plugins/baseball-scoreboard/data_sources.py | 12 +++-- .../baseball-scoreboard/logo_downloader.py | 4 +- plugins/baseball-scoreboard/milb_managers.py | 2 +- .../base_odds_manager.py | 4 +- plugins/basketball-scoreboard/sports.py | 12 ++--- .../football-scoreboard/base_odds_manager.py | 4 +- plugins/football-scoreboard/sports.py | 24 ++++----- .../hockey-scoreboard/base_odds_manager.py | 4 +- plugins/hockey-scoreboard/sports.py | 12 ++--- .../soccer-scoreboard/base_odds_manager.py | 4 +- plugins/soccer-scoreboard/manager.py | 14 +++-- 13 files changed, 79 insertions(+), 78 deletions(-) diff --git a/plugins/baseball-scoreboard/base_odds_manager.py b/plugins/baseball-scoreboard/base_odds_manager.py index 6763e70..af59e0f 100644 --- a/plugins/baseball-scoreboard/base_odds_manager.py +++ b/plugins/baseball-scoreboard/base_odds_manager.py @@ -161,12 +161,12 @@ def get_odds( self.logger.debug("No odds data available for this game") if odds_data: - self.cache_manager.set(cache_key, odds_data) + self.cache_manager.set(cache_key, odds_data, ttl=interval) self.logger.info(f"Saved odds data to cache for {cache_key}") else: self.logger.debug(f"No odds data available for {cache_key}") # Cache the fact that no odds are available to avoid repeated API calls - self.cache_manager.set(cache_key, {"no_odds": True}) + self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval) return odds_data @@ -285,9 +285,9 @@ def clear_cache(self, sport: str = None, league: str = None, event_id: str = Non if sport and league and event_id: # Clear specific event cache_key = f"odds_espn_{sport}_{league}_{event_id}" - self.cache_manager.delete(cache_key) + self.cache_manager.clear_cache(cache_key) self.logger.info(f"Cleared cache for {cache_key}") else: # Clear all odds cache - self.cache_manager.clear() + self.cache_manager.clear_cache() self.logger.info("Cleared all cache") diff --git a/plugins/baseball-scoreboard/baseball.py b/plugins/baseball-scoreboard/baseball.py index ad226cd..8422a19 100644 --- a/plugins/baseball-scoreboard/baseball.py +++ b/plugins/baseball-scoreboard/baseball.py @@ -614,35 +614,38 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: # Center horizontally within the BASE cluster width count_x = bases_origin_x + (base_cluster_width - count_text_width) // 2 - # Ensure draw object is set and draw text + # Temporarily set draw object for BDF text rendering, then restore + original_draw = self.display_manager.draw self.display_manager.draw = draw_overlay + try: + # Draw Balls-Strikes Count with outline using BDF font + outline_color_for_bdf = (0, 0, 0) + + # Draw outline + 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, + ) - # Draw Balls-Strikes Count with outline using BDF font - outline_color_for_bdf = (0, 0, 0) - - # Draw outline - for dx_offset, dy_offset in [ - (-1, -1), - (-1, 0), - (-1, 1), - (0, -1), - (0, 1), - (1, -1), - (1, 0), - (1, 1), - ]: + # Draw main text self.display_manager._draw_bdf_text( - count_text, - count_x + dx_offset, - count_y + dy_offset, - color=outline_color_for_bdf, - font=bdf_font, + count_text, count_x, count_y, color=text_color, font=bdf_font ) - - # Draw main text - self.display_manager._draw_bdf_text( - count_text, count_x, count_y, color=text_color, font=bdf_font - ) + finally: + self.display_manager.draw = original_draw # Draw Team:Score at the bottom (matching main branch format) score_font = self.display_manager.font # Use PressStart2P diff --git a/plugins/baseball-scoreboard/data_sources.py b/plugins/baseball-scoreboard/data_sources.py index 76c1644..616c958 100644 --- a/plugins/baseball-scoreboard/data_sources.py +++ b/plugins/baseball-scoreboard/data_sources.py @@ -75,8 +75,13 @@ def fetch_live_games(self, sport: str, league: str) -> List[Dict]: events = data.get('events', []) # Filter for live games - live_events = [event for event in events - if event.get('competitions', [{}])[0].get('status', {}).get('type', {}).get('state') == 'in'] + live_events = [] + for event in events: + competitions = event.get('competitions', []) + if not competitions: + continue + if competitions[0].get('status', {}).get('type', {}).get('state') == 'in': + live_events.append(event) self.logger.debug(f"Fetched {len(live_events)} live games for {sport}/{league}") return live_events @@ -162,7 +167,8 @@ def fetch_live_games(self, sport: str, league: str) -> List[Dict]: response.raise_for_status() data = response.json() - games = data.get('dates', [{}])[0].get('games', []) + dates = data.get('dates', []) + games = dates[0].get('games', []) if dates else [] # Filter for live games live_games = [game for game in games diff --git a/plugins/baseball-scoreboard/logo_downloader.py b/plugins/baseball-scoreboard/logo_downloader.py index 82fbc6e..0169c76 100644 --- a/plugins/baseball-scoreboard/logo_downloader.py +++ b/plugins/baseball-scoreboard/logo_downloader.py @@ -61,6 +61,8 @@ def get_logo_filename_variations(abbr: str) -> List[str]: return variations +_downloader = LogoDownloader() + def download_missing_logo(sport_key: str, team_id: str, team_abbr: str, logo_path: Path, logo_url: str = None) -> bool: """ Download missing logo for a team. @@ -101,7 +103,7 @@ def download_missing_logo(sport_key: str, team_id: str, team_abbr: str, logo_pat # If we have a logo URL, try to download it if logo_url: try: - response = requests.get(logo_url, timeout=30) + response = _downloader.session.get(logo_url, headers=_downloader.headers, timeout=_downloader.request_timeout) if response.status_code == 200: # Verify it's an image content_type = response.headers.get('content-type', '').lower() diff --git a/plugins/baseball-scoreboard/milb_managers.py b/plugins/baseball-scoreboard/milb_managers.py index d2a4012..7db6fe4 100644 --- a/plugins/baseball-scoreboard/milb_managers.py +++ b/plugins/baseball-scoreboard/milb_managers.py @@ -9,7 +9,7 @@ from sports import SportsUpcoming # MiLB uses the MLB Stats API (ESPN does not support MiLB scoreboard) -MLB_STATS_BASE_URL = "http://statsapi.mlb.com/api/v1" +MLB_STATS_BASE_URL = "https://statsapi.mlb.com/api/v1" # Default MiLB sport IDs: AAA=11, AA=12, High-A=13, Single-A=14 DEFAULT_MILB_SPORT_IDS = [11, 12, 13, 14] diff --git a/plugins/basketball-scoreboard/base_odds_manager.py b/plugins/basketball-scoreboard/base_odds_manager.py index 2dffbf6..3703bb3 100644 --- a/plugins/basketball-scoreboard/base_odds_manager.py +++ b/plugins/basketball-scoreboard/base_odds_manager.py @@ -165,12 +165,12 @@ def get_odds( self.logger.debug("No odds data available for this game") if odds_data: - self.cache_manager.set(cache_key, odds_data) + self.cache_manager.set(cache_key, odds_data, ttl=interval) self.logger.info(f"Saved odds data to cache for {cache_key}") else: self.logger.debug(f"No odds data available for {cache_key}") # Cache the fact that no odds are available to avoid repeated API calls - self.cache_manager.set(cache_key, {"no_odds": True}) + self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval) return odds_data diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 64912e6..bdc65ef 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -2238,15 +2238,13 @@ def _is_game_really_over(self, game: Dict) -> bool: # Check if clock is 0:00 in Q4 or OT (period >= 4) raw_clock = game.get("clock") - if raw_clock is None or not isinstance(raw_clock, str): - clock = "0:00" - else: - clock = raw_clock period = game.get("period", 0) - clock_normalized = clock.replace(":", "").strip() - if period >= 4: - if clock_normalized in ("000", "00", "") or clock in ("0:00", ":00"): + # Only check clock-based finish if we have a valid clock string + if isinstance(raw_clock, str) and raw_clock.strip() and period >= 4: + clock = raw_clock + clock_normalized = clock.replace(":", "").strip() + if clock_normalized in ("000", "00") or clock in ("0:00", ":00"): self.logger.debug( f"_is_game_really_over({game_str}): " f"returning True - clock at 0:00 (clock='{clock}', period={period})" diff --git a/plugins/football-scoreboard/base_odds_manager.py b/plugins/football-scoreboard/base_odds_manager.py index 849204c..7bbbbc3 100644 --- a/plugins/football-scoreboard/base_odds_manager.py +++ b/plugins/football-scoreboard/base_odds_manager.py @@ -160,12 +160,12 @@ def get_odds( self.logger.debug("No odds data available for this game") if odds_data: - self.cache_manager.set(cache_key, odds_data) + self.cache_manager.set(cache_key, odds_data, ttl=interval) self.logger.info(f"Saved odds data to cache for {cache_key}") else: self.logger.debug(f"No odds data available for {cache_key}") # Cache the fact that no odds are available to avoid repeated API calls - self.cache_manager.set(cache_key, {"no_odds": True}) + self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval) return odds_data diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index 3c27b8a..b62dbc3 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -2068,26 +2068,22 @@ def _is_game_really_over(self, game: Dict) -> bool: return True # Check if clock is 0:00 in Q4 or OT - # Safely coerce clock to string to handle None or non-string values raw_clock = game.get("clock") - if raw_clock is None or not isinstance(raw_clock, str): - clock = "0:00" - else: - clock = raw_clock period = game.get("period", 0) - # Handle various clock formats: "0:00", ":00", "0", ":40" (stuck at :40) - clock_normalized = clock.replace(":", "").strip() - self.logger.debug( - f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " - f"raw_clock={raw_clock!r}, clock='{clock}', clock_normalized='{clock_normalized}', period={period}, period_text='{period_text}'" - ) + # Only check clock-based finish if we have a valid clock string + if isinstance(raw_clock, str) and raw_clock.strip() and period >= 4: + clock = raw_clock + clock_normalized = clock.replace(":", "").strip() + + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " + f"raw_clock={raw_clock!r}, clock='{clock}', clock_normalized='{clock_normalized}', period={period}, period_text='{period_text}'" + ) - if period >= 4: - # In Q4 or OT, if clock is 0:00 or appears stuck (like :40), consider it over # Check for clock at 0:00 - various formats: "0:00", ":00", normalized "000"/"00" # Note: Clocks like ":40", ":50" are legitimate (under 1 minute remaining) - if clock_normalized == "000" or clock_normalized == "00" or clock == "0:00" or clock == ":00": + if clock_normalized in ("000", "00") or clock in ("0:00", ":00"): self.logger.debug( f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " f"returning True - clock appears to be 0:00 (clock='{clock}', normalized='{clock_normalized}', period={period})" diff --git a/plugins/hockey-scoreboard/base_odds_manager.py b/plugins/hockey-scoreboard/base_odds_manager.py index 849204c..7bbbbc3 100644 --- a/plugins/hockey-scoreboard/base_odds_manager.py +++ b/plugins/hockey-scoreboard/base_odds_manager.py @@ -160,12 +160,12 @@ def get_odds( self.logger.debug("No odds data available for this game") if odds_data: - self.cache_manager.set(cache_key, odds_data) + self.cache_manager.set(cache_key, odds_data, ttl=interval) self.logger.info(f"Saved odds data to cache for {cache_key}") else: self.logger.debug(f"No odds data available for {cache_key}") # Cache the fact that no odds are available to avoid repeated API calls - self.cache_manager.set(cache_key, {"no_odds": True}) + self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval) return odds_data diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 44a0976..58112f8 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -1963,15 +1963,13 @@ def _is_game_really_over(self, game: Dict) -> bool: # Check if clock is 0:00 in P3 or OT (period >= 3) raw_clock = game.get("clock") - if raw_clock is None or not isinstance(raw_clock, str): - clock = "0:00" - else: - clock = raw_clock period = game.get("period", 0) - clock_normalized = clock.replace(":", "").strip() - if period >= 3: - if clock_normalized in ("000", "00", "") or clock in ("0:00", ":00"): + # Only check clock-based finish if we have a valid clock string + if isinstance(raw_clock, str) and raw_clock.strip() and period >= 3: + clock = raw_clock + clock_normalized = clock.replace(":", "").strip() + if clock_normalized in ("000", "00") or clock in ("0:00", ":00"): self.logger.debug( f"_is_game_really_over({game_str}): " f"returning True - clock at 0:00 (clock='{clock}', period={period})" diff --git a/plugins/soccer-scoreboard/base_odds_manager.py b/plugins/soccer-scoreboard/base_odds_manager.py index 871f0f0..8875048 100644 --- a/plugins/soccer-scoreboard/base_odds_manager.py +++ b/plugins/soccer-scoreboard/base_odds_manager.py @@ -157,12 +157,12 @@ def get_odds( self.logger.debug("No odds data available for this game") if odds_data: - self.cache_manager.set(cache_key, odds_data) + self.cache_manager.set(cache_key, odds_data, ttl=interval) self.logger.info(f"Saved odds data to cache for {cache_key}") else: self.logger.debug(f"No odds data available for {cache_key}") # Cache the fact that no odds are available to avoid repeated API calls - self.cache_manager.set(cache_key, {"no_odds": True}) + self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval) return odds_data diff --git a/plugins/soccer-scoreboard/manager.py b/plugins/soccer-scoreboard/manager.py index 1c55f38..94cd0e2 100644 --- a/plugins/soccer-scoreboard/manager.py +++ b/plugins/soccer-scoreboard/manager.py @@ -1943,9 +1943,8 @@ def get_cycle_duration(self, display_mode: str = None) -> Optional[float]: return effective_duration # No mode-level duration - use dynamic calculation - # Count games across all enabled leagues for this mode type - total_games = 0 - game_duration = 15.0 + # Accumulate per-league (games * duration) to handle different durations per league + total_duration = 0.0 for league_key, league_data in self._league_registry.items(): if not league_data.get('enabled', False): @@ -1953,14 +1952,13 @@ def get_cycle_duration(self, display_mode: str = None) -> Optional[float]: manager = league_data.get('managers', {}).get(mode_type) if manager: games = getattr(manager, 'games', []) - total_games += len(games) - game_duration = self._get_game_duration(league_key, mode_type, manager) + if games: + game_duration = self._get_game_duration(league_key, mode_type, manager) + total_duration += len(games) * game_duration - if total_games == 0: + if total_duration == 0.0: return None - total_duration = total_games * game_duration - # Apply dynamic cap if configured if self._dynamic_feature_enabled(): cap = self.get_dynamic_duration_cap() From f861ba0c5ea7dfd81bc1d860d645561c66bec52d Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 14 Feb 2026 20:07:05 -0500 Subject: [PATCH 09/15] =?UTF-8?q?fix:=20address=20PR=20nitpicks=20?= =?UTF-8?q?=E2=80=94=20remove=20duplicates,=20unused=20params,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate _get_games_from_manager and _get_rankings_cache definitions in hockey/manager.py that shadowed the original methods; pass mode_type in Vegas scroll calls that already had it in scope - Remove unused game_date variable in milb_managers.py - Use bool(offense.get()) for base runner detection in milb_managers.py instead of key-existence check that could misread null values - Simplify odds check in baseball.py to game.get("odds") - Remove unused Dict/Any/Tuple imports from logo_downloader.py - Remove unused game parameter from game_renderer._get_logo_path and _load_and_resize_logo, along with all 6 call sites Co-Authored-By: Claude Opus 4.6 --- plugins/baseball-scoreboard/baseball.py | 2 +- plugins/baseball-scoreboard/game_renderer.py | 18 +++---- .../baseball-scoreboard/logo_downloader.py | 2 +- plugins/baseball-scoreboard/milb_managers.py | 7 ++- plugins/hockey-scoreboard/manager.py | 51 ++----------------- 5 files changed, 17 insertions(+), 63 deletions(-) diff --git a/plugins/baseball-scoreboard/baseball.py b/plugins/baseball-scoreboard/baseball.py index 8422a19..ceec46e 100644 --- a/plugins/baseball-scoreboard/baseball.py +++ b/plugins/baseball-scoreboard/baseball.py @@ -697,7 +697,7 @@ def draw_bottom_outlined_text(x, y, text): draw_bottom_outlined_text(home_score_x, score_y, home_text) # Draw gambling odds if available - if "odds" in game and game["odds"]: + if game.get("odds"): self._draw_dynamic_odds( draw_overlay, game["odds"], self.display_width, self.display_height ) diff --git a/plugins/baseball-scoreboard/game_renderer.py b/plugins/baseball-scoreboard/game_renderer.py index 9f46f63..36c3fb8 100644 --- a/plugins/baseball-scoreboard/game_renderer.py +++ b/plugins/baseball-scoreboard/game_renderer.py @@ -77,7 +77,7 @@ def _load_fonts(self): fonts['rank'] = ImageFont.load_default() return fonts - def _get_logo_path(self, league: str, team_abbrev: str, game: Dict = None) -> Path: + def _get_logo_path(self, league: str, team_abbrev: str) -> Path: """Get the logo path for a team based on league.""" if league == 'mlb': return Path("assets/sports/mlb_logos") / f"{team_abbrev}.png" @@ -88,13 +88,13 @@ def _get_logo_path(self, league: str, team_abbrev: str, game: Dict = None) -> Pa else: return Path("assets/sports/mlb_logos") / f"{team_abbrev}.png" - def _load_and_resize_logo(self, league: str, team_abbrev: str, game: Dict = None) -> Optional[Image.Image]: + def _load_and_resize_logo(self, league: str, team_abbrev: str) -> 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, game) + logo_path = self._get_logo_path(league, team_abbrev) if not logo_path.exists(): self.logger.warning(f"Logo not found for {team_abbrev} at {logo_path}") @@ -161,8 +161,8 @@ def _render_live_game(self, game: Dict) -> Image.Image: draw = ImageDraw.Draw(overlay) league = game.get('league', 'mlb') - home_logo = self._load_and_resize_logo(league, game.get('home_abbr', ''), game) - away_logo = self._load_and_resize_logo(league, game.get('away_abbr', ''), game) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) if not home_logo or not away_logo: return self._render_error_card("Logo Error") @@ -298,8 +298,8 @@ def _render_recent_game(self, game: Dict) -> Image.Image: draw = ImageDraw.Draw(overlay) league = game.get('league', 'mlb') - home_logo = self._load_and_resize_logo(league, game.get('home_abbr', ''), game) - away_logo = self._load_and_resize_logo(league, game.get('away_abbr', ''), game) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) if not home_logo or not away_logo: return self._render_error_card("Logo Error") @@ -344,8 +344,8 @@ def _render_upcoming_game(self, game: Dict) -> Image.Image: draw = ImageDraw.Draw(overlay) league = game.get('league', 'mlb') - home_logo = self._load_and_resize_logo(league, game.get('home_abbr', ''), game) - away_logo = self._load_and_resize_logo(league, game.get('away_abbr', ''), game) + home_logo = self._load_and_resize_logo(league, game.get('home_abbr', '')) + away_logo = self._load_and_resize_logo(league, game.get('away_abbr', '')) if not home_logo or not away_logo: return self._render_error_card("Logo Error") diff --git a/plugins/baseball-scoreboard/logo_downloader.py b/plugins/baseball-scoreboard/logo_downloader.py index 0169c76..2dfb737 100644 --- a/plugins/baseball-scoreboard/logo_downloader.py +++ b/plugins/baseball-scoreboard/logo_downloader.py @@ -5,7 +5,7 @@ import os import logging import requests -from typing import Dict, Any, List, Optional, Tuple +from typing import List, Optional from pathlib import Path from PIL import Image, ImageDraw, ImageFont from requests.adapters import HTTPAdapter diff --git a/plugins/baseball-scoreboard/milb_managers.py b/plugins/baseball-scoreboard/milb_managers.py index 7db6fe4..ff81793 100644 --- a/plugins/baseball-scoreboard/milb_managers.py +++ b/plugins/baseball-scoreboard/milb_managers.py @@ -108,7 +108,6 @@ def _convert_stats_game_to_espn_event(game: Dict) -> Dict: detail_text = f"{inning_state} {inning_ordinal}" short_detail = f"{half} {inning_ordinal}" else: - game_date = game.get("gameDate", "") detail_text = detailed_state or "Scheduled" short_detail = detail_text @@ -128,9 +127,9 @@ def _convert_stats_game_to_espn_event(game: Dict) -> Dict: offense = linescore.get("offense", {}) situation = { "outs": linescore.get("outs", 0), - "onFirst": "first" in offense, - "onSecond": "second" in offense, - "onThird": "third" in offense, + "onFirst": bool(offense.get("first")), + "onSecond": bool(offense.get("second")), + "onThird": bool(offense.get("third")), "count": { "balls": linescore.get("balls", 0), "strikes": linescore.get("strikes", 0), diff --git a/plugins/hockey-scoreboard/manager.py b/plugins/hockey-scoreboard/manager.py index f484378..7bf0e04 100644 --- a/plugins/hockey-scoreboard/manager.py +++ b/plugins/hockey-scoreboard/manager.py @@ -2237,40 +2237,6 @@ def _should_use_scroll_mode(self, league: str, mode_type: str) -> bool: True if this league uses scroll mode for this game type """ return self._get_display_mode(league, mode_type) == 'scroll' - - def _get_games_from_manager(self, manager, mode_type: str) -> List[Dict]: - """Get games list from a manager based on mode type.""" - if mode_type == 'live': - return list(getattr(manager, 'live_games', []) or []) - elif mode_type == 'recent': - # Try games_list first (used by recent managers), then recent_games - games = getattr(manager, 'games_list', None) - if games is None: - games = getattr(manager, 'recent_games', []) - return list(games or []) - elif mode_type == 'upcoming': - # Try games_list first (used by upcoming managers), then upcoming_games - games = getattr(manager, 'games_list', None) - if games is None: - games = getattr(manager, 'upcoming_games', []) - return list(games or []) - return [] - - def _get_rankings_cache(self) -> Dict[str, int]: - """Get combined team rankings cache from all managers.""" - rankings = {} - - # Try to get rankings from each manager - for manager_attr in ['nhl_live', 'nhl_recent', 'nhl_upcoming', - 'ncaa_mens_live', 'ncaa_mens_recent', 'ncaa_mens_upcoming', - 'ncaa_womens_live', 'ncaa_womens_recent', 'ncaa_womens_upcoming']: - manager = getattr(self, manager_attr, None) - if manager: - manager_rankings = getattr(manager, '_team_rankings_cache', {}) - if manager_rankings: - rankings.update(manager_rankings) - - return rankings def _display_scroll_mode(self, display_mode: str, league: str, mode_type: str, force_clear: bool) -> bool: """Handle display for scroll mode (single league). @@ -3030,7 +2996,7 @@ def _collect_games_for_scroll(self) -> tuple: for mode_type in ['live', 'recent', 'upcoming']: manager = self._get_manager_for_league_mode('nhl', mode_type) if manager: - games = self._get_games_from_manager(manager) + games = self._get_games_from_manager(manager, mode_type) for game in games: game['league'] = 'nhl' # Ensure game has status for type determination @@ -3052,7 +3018,7 @@ def _collect_games_for_scroll(self) -> tuple: for mode_type in ['live', 'recent', 'upcoming']: manager = self._get_manager_for_league_mode('ncaam_hockey', mode_type) if manager: - games = self._get_games_from_manager(manager) + games = self._get_games_from_manager(manager, mode_type) for game in games: game['league'] = 'ncaam_hockey' # Ensure game has status for type determination @@ -3073,7 +3039,7 @@ def _collect_games_for_scroll(self) -> tuple: for mode_type in ['live', 'recent', 'upcoming']: manager = self._get_manager_for_league_mode('ncaaw_hockey', mode_type) if manager: - games = self._get_games_from_manager(manager) + games = self._get_games_from_manager(manager, mode_type) for game in games: game['league'] = 'ncaaw_hockey' # Ensure game has status for type determination @@ -3115,17 +3081,6 @@ def _get_manager_for_league_mode(self, league: str, mode_type: str): return self.ncaa_womens_upcoming return None - def _get_games_from_manager(self, manager): - """Extract games list from a manager.""" - if not manager: - return [] - # Try different attribute names - for attr in ['live_games', 'games_list', 'recent_games', 'upcoming_games']: - games = getattr(manager, attr, None) - if games and isinstance(games, list): - return games - return [] - # ------------------------------------------------------------------------- # Vegas scroll mode support # ------------------------------------------------------------------------- From 3949a952a63e1a47e597f2e23242900b28033ce4 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 14 Feb 2026 20:15:50 -0500 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20minor=20code=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20len(None)=20crash,=20error-path=20rendering,=20bare?= =?UTF-8?q?=20excepts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix len(result.data.get('events')) crash in mlb/ncaa background callbacks - Fix error-path rendering in baseball.py (text drawn on discarded copy) - Remove unused font variable in baseball.py display_series_summary - Fix bare except clauses in logo_downloader.py - Fix placeholder User-Agent string in logo_downloader.py - Fix stray f-string prefixes in data_sources.py (baseball + soccer) - Fix comment mismatch (130% → 150%) in logo_manager.py - Change MiLB show_odds default to false in config_schema.json Co-Authored-By: Claude Opus 4.6 --- plugins/baseball-scoreboard/baseball.py | 6 +++--- plugins/baseball-scoreboard/config_schema.json | 2 +- plugins/baseball-scoreboard/data_sources.py | 4 ++-- plugins/baseball-scoreboard/logo_downloader.py | 6 +++--- plugins/baseball-scoreboard/logo_manager.py | 4 ++-- plugins/baseball-scoreboard/mlb_managers.py | 2 +- plugins/baseball-scoreboard/ncaa_baseball_managers.py | 2 +- plugins/soccer-scoreboard/data_sources.py | 4 ++-- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/plugins/baseball-scoreboard/baseball.py b/plugins/baseball-scoreboard/baseball.py index ceec46e..dac8800 100644 --- a/plugins/baseball-scoreboard/baseball.py +++ b/plugins/baseball-scoreboard/baseball.py @@ -327,7 +327,6 @@ def display_series_summary(self, game: dict, draw_overlay: ImageDraw.ImageDraw): return series_summary = game.get("series_summary", "") - font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) bbox = draw_overlay.textbbox((0, 0), series_summary, font=self.fonts['time']) height = bbox[3] - bbox[1] shots_y = (self.display_height - height) // 2 @@ -421,11 +420,12 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: f"Failed to load logos for live game: {game.get('id')}" ) # Draw placeholder text if logos fail - draw_final = ImageDraw.Draw(main_img.convert("RGB")) + error_img = main_img.convert("RGB") + draw_final = ImageDraw.Draw(error_img) 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.image.paste(error_img, (0, 0)) self.display_manager.update_display() return diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index e057c29..97d3d7a 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -417,7 +417,7 @@ }, "show_odds": { "type": "boolean", - "default": true, + "default": false, "description": "Show betting odds" }, "show_series_summary": { diff --git a/plugins/baseball-scoreboard/data_sources.py b/plugins/baseball-scoreboard/data_sources.py index 616c958..8f2afde 100644 --- a/plugins/baseball-scoreboard/data_sources.py +++ b/plugins/baseball-scoreboard/data_sources.py @@ -223,7 +223,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: response.raise_for_status() data = response.json() - self.logger.debug(f"Fetched standings from MLB API") + self.logger.debug("Fetched standings from MLB API") return data except Exception as e: @@ -302,7 +302,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: response.raise_for_status() data = response.json() - self.logger.debug(f"Fetched standings from soccer API") + self.logger.debug("Fetched standings from soccer API") return data except Exception as e: diff --git a/plugins/baseball-scoreboard/logo_downloader.py b/plugins/baseball-scoreboard/logo_downloader.py index 2dfb737..6a842fd 100644 --- a/plugins/baseball-scoreboard/logo_downloader.py +++ b/plugins/baseball-scoreboard/logo_downloader.py @@ -35,7 +35,7 @@ def __init__(self, request_timeout: int = 30, retry_attempts: int = 3): # Set up headers self.headers = { - 'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)', + 'User-Agent': 'LEDMatrix/2.0 (https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json', 'Accept-Language': 'en-US,en;q=0.9', 'Accept-Encoding': 'gzip, deflate, br', @@ -131,7 +131,7 @@ def download_missing_logo(sport_key: str, team_id: str, team_abbr: str, logo_pat # Try to create placeholder as fallback try: return create_placeholder_logo(team_abbr, logo_path) - except: + except Exception: return False def create_placeholder_logo(team_abbr: str, logo_path: Path) -> bool: @@ -147,7 +147,7 @@ def create_placeholder_logo(team_abbr: str, logo_path: Path) -> bool: # Try to load a font try: font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) - except: + except (IOError, OSError): font = ImageFont.load_default() # Draw team abbreviation diff --git a/plugins/baseball-scoreboard/logo_manager.py b/plugins/baseball-scoreboard/logo_manager.py index 5f49993..9961791 100644 --- a/plugins/baseball-scoreboard/logo_manager.py +++ b/plugins/baseball-scoreboard/logo_manager.py @@ -117,7 +117,7 @@ def load_logo(self, team_id: str, team_abbr: str, logo_path: Path, self.logger.error(f"Logo file still doesn't exist at {actual_logo_path} after download attempt") return None - # Resize to fit display (130% of display dimensions to allow extending off screen) + # Resize to fit display (150% 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), RESAMPLE_FILTER) @@ -158,7 +158,7 @@ def load_milb_logo(self, team_abbr: str, logo_dir: Path) -> Optional[Image.Image self.logger.warning(f"MiLB logo not found for {team_abbr} at {logo_path}") return None - # Resize to fit display (130% of display dimensions) + # Resize to fit display (150% 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), RESAMPLE_FILTER) diff --git a/plugins/baseball-scoreboard/mlb_managers.py b/plugins/baseball-scoreboard/mlb_managers.py index fe30312..04e731a 100644 --- a/plugins/baseball-scoreboard/mlb_managers.py +++ b/plugins/baseball-scoreboard/mlb_managers.py @@ -93,7 +93,7 @@ def fetch_callback(result): """Callback when background fetch completes.""" if result.success: self.logger.info( - f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events" + f"Background fetch completed for {season_year}: {len(result.data.get('events', []))} events" ) else: self.logger.error( diff --git a/plugins/baseball-scoreboard/ncaa_baseball_managers.py b/plugins/baseball-scoreboard/ncaa_baseball_managers.py index 52a0458..2155267 100644 --- a/plugins/baseball-scoreboard/ncaa_baseball_managers.py +++ b/plugins/baseball-scoreboard/ncaa_baseball_managers.py @@ -104,7 +104,7 @@ def fetch_callback(result): """Callback when background fetch completes.""" if result.success: self.logger.info( - f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events" + f"Background fetch completed for {season_year}: {len(result.data.get('events', []))} events" ) else: self.logger.error( diff --git a/plugins/soccer-scoreboard/data_sources.py b/plugins/soccer-scoreboard/data_sources.py index ab2a102..ad95fd5 100644 --- a/plugins/soccer-scoreboard/data_sources.py +++ b/plugins/soccer-scoreboard/data_sources.py @@ -238,7 +238,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: response.raise_for_status() data = response.json() - self.logger.debug(f"Fetched standings from MLB API") + self.logger.debug("Fetched standings from MLB API") return data except Exception as e: @@ -317,7 +317,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: response.raise_for_status() data = response.json() - self.logger.debug(f"Fetched standings from soccer API") + self.logger.debug("Fetched standings from soccer API") return data except Exception as e: From 9c903bce49f2236c344842bec261831a49bd16d7 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 14 Feb 2026 20:49:11 -0500 Subject: [PATCH 11/15] fix: odds sentinel leak, stale game detection, duration bounds, and cache TTL - Filter out {"no_odds": True} sentinel in error-path cache fallback across all 5 base_odds_manager.py files - Fix _detect_stale_games to operate on persisted self.live_games instead of freshly-built new_live_games (basketball, football, hockey) - Add minimum/maximum bounds (10-120) to recent/upcoming_game_duration in baseball config_schema.json (MLB, MiLB, NCAA sections) - Add 4-hour TTL to synchronous fallback cache in mlb/ncaa/milb_managers - Change MiLB situation field from None to empty dict to prevent downstream AttributeError Co-Authored-By: Claude Opus 4.6 --- plugins/baseball-scoreboard/base_odds_manager.py | 6 +++++- plugins/baseball-scoreboard/config_schema.json | 12 ++++++++++++ plugins/baseball-scoreboard/milb_managers.py | 6 +++--- plugins/baseball-scoreboard/mlb_managers.py | 4 ++-- .../baseball-scoreboard/ncaa_baseball_managers.py | 4 ++-- plugins/basketball-scoreboard/base_odds_manager.py | 6 +++++- plugins/basketball-scoreboard/sports.py | 6 ++++-- plugins/football-scoreboard/base_odds_manager.py | 6 +++++- plugins/football-scoreboard/sports.py | 6 ++++-- plugins/hockey-scoreboard/base_odds_manager.py | 6 +++++- plugins/hockey-scoreboard/sports.py | 6 ++++-- plugins/soccer-scoreboard/base_odds_manager.py | 6 +++++- 12 files changed, 56 insertions(+), 18 deletions(-) diff --git a/plugins/baseball-scoreboard/base_odds_manager.py b/plugins/baseball-scoreboard/base_odds_manager.py index af59e0f..94ed526 100644 --- a/plugins/baseball-scoreboard/base_odds_manager.py +++ b/plugins/baseball-scoreboard/base_odds_manager.py @@ -177,7 +177,11 @@ def get_odds( f"Error decoding JSON response from ESPN API for {cache_key}." ) - return self.cache_manager.get(cache_key) + # Return cached odds on error, but filter out the no_odds sentinel + cached = self.cache_manager.get(cache_key) + if isinstance(cached, dict) and cached.get("no_odds"): + return None + return cached def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: """ diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 97d3d7a..0f2576b 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -87,11 +87,15 @@ "recent_game_duration": { "type": "number", "default": 15, + "minimum": 10, + "maximum": 120, "description": "Duration in seconds to show each recent game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." }, "upcoming_game_duration": { "type": "number", "default": 15, + "minimum": 10, + "maximum": 120, "description": "Duration in seconds to show each upcoming game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." }, "live_update_interval": { @@ -358,11 +362,15 @@ "recent_game_duration": { "type": "number", "default": 15, + "minimum": 10, + "maximum": 120, "description": "Duration in seconds to show each recent game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." }, "upcoming_game_duration": { "type": "number", "default": 15, + "minimum": 10, + "maximum": 120, "description": "Duration in seconds to show each upcoming game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." }, "live_update_interval": { @@ -629,11 +637,15 @@ "recent_game_duration": { "type": "number", "default": 15, + "minimum": 10, + "maximum": 120, "description": "Duration in seconds to show each recent game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." }, "upcoming_game_duration": { "type": "number", "default": 15, + "minimum": 10, + "maximum": 120, "description": "Duration in seconds to show each upcoming game before rotating to the next game. If not set, uses the top-level game_display_duration setting (default: 15 seconds)." }, "live_update_interval": { diff --git a/plugins/baseball-scoreboard/milb_managers.py b/plugins/baseball-scoreboard/milb_managers.py index ff81793..a261115 100644 --- a/plugins/baseball-scoreboard/milb_managers.py +++ b/plugins/baseball-scoreboard/milb_managers.py @@ -194,7 +194,7 @@ def _convert_stats_game_to_espn_event(game: Dict) -> Dict: "period": current_inning or 0, "displayClock": "0:00", }, - "situation": situation if situation else None, + "situation": situation or {}, "odds": [], "series": {}, } @@ -304,9 +304,9 @@ def _fetch_milb_api_data(self, use_cache: bool = True) -> Optional[Dict]: data = self._fetch_from_mlb_stats_api(dates) - # Cache the result + # Cache the result with 4-hour TTL so it refreshes periodically if data and data.get("events"): - self.cache_manager.set(cache_key, data) + self.cache_manager.set(cache_key, data, ttl=14400) self.logger.info( f"Cached {len(data['events'])} MiLB events for {season_year}" ) diff --git a/plugins/baseball-scoreboard/mlb_managers.py b/plugins/baseball-scoreboard/mlb_managers.py index 04e731a..4673fec 100644 --- a/plugins/baseball-scoreboard/mlb_managers.py +++ b/plugins/baseball-scoreboard/mlb_managers.py @@ -146,8 +146,8 @@ def fetch_callback(result): response.raise_for_status() data = response.json() - # Cache the data - self.cache_manager.set(cache_key, data) + # Cache the data with 4-hour TTL so it refreshes periodically + self.cache_manager.set(cache_key, data, ttl=14400) self.logger.info(f"Synchronously fetched {season_year} season schedule") return data diff --git a/plugins/baseball-scoreboard/ncaa_baseball_managers.py b/plugins/baseball-scoreboard/ncaa_baseball_managers.py index 2155267..a7ac680 100644 --- a/plugins/baseball-scoreboard/ncaa_baseball_managers.py +++ b/plugins/baseball-scoreboard/ncaa_baseball_managers.py @@ -151,8 +151,8 @@ def fetch_callback(result): response.raise_for_status() data = response.json() - # Cache the data - self.cache_manager.set(cache_key, data) + # Cache the data with 4-hour TTL so it refreshes periodically + self.cache_manager.set(cache_key, data, ttl=14400) self.logger.info(f"Synchronously fetched {season_year} season schedule") return data diff --git a/plugins/basketball-scoreboard/base_odds_manager.py b/plugins/basketball-scoreboard/base_odds_manager.py index 3703bb3..f457ba0 100644 --- a/plugins/basketball-scoreboard/base_odds_manager.py +++ b/plugins/basketball-scoreboard/base_odds_manager.py @@ -181,7 +181,11 @@ def get_odds( f"Error decoding JSON response from ESPN API for {cache_key}." ) - return self.cache_manager.get(cache_key) + # Return cached odds on error, but filter out the no_odds sentinel + cached = self.cache_manager.get(cache_key) + if isinstance(cached, dict) and cached.get("no_odds"): + return None + return cached def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: """ diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index bdc65ef..9890b7d 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -2429,8 +2429,10 @@ def update(self): self._fetch_odds(details) new_live_games.append(details) - # Detect and remove stale games - self._detect_stale_games(new_live_games) + # Detect and remove stale games from persisted list + # (new_live_games has fresh last_seen, so stale check must + # run against the previous self.live_games) + self._detect_stale_games(self.live_games) self.logger.info( f"Live game filtering: {total_events} total events, " diff --git a/plugins/football-scoreboard/base_odds_manager.py b/plugins/football-scoreboard/base_odds_manager.py index 7bbbbc3..69b65b3 100644 --- a/plugins/football-scoreboard/base_odds_manager.py +++ b/plugins/football-scoreboard/base_odds_manager.py @@ -176,7 +176,11 @@ def get_odds( f"Error decoding JSON response from ESPN API for {cache_key}." ) - return self.cache_manager.get(cache_key) + # Return cached odds on error, but filter out the no_odds sentinel + cached = self.cache_manager.get(cache_key) + if isinstance(cached, dict) and cached.get("no_odds"): + return None + return cached def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: """ diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index b62dbc3..bf7707c 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -2331,8 +2331,10 @@ def update(self): f"favorite_teams={self.favorite_teams if self.favorite_teams else '[] (showing all)'}" ) - # Detect and remove stale games - self._detect_stale_games(new_live_games) + # Detect and remove stale games from persisted list + # (new_live_games has fresh last_seen, so stale check must + # run against the previous self.live_games) + self._detect_stale_games(self.live_games) # Log changes or periodically current_time_for_log = ( diff --git a/plugins/hockey-scoreboard/base_odds_manager.py b/plugins/hockey-scoreboard/base_odds_manager.py index 7bbbbc3..69b65b3 100644 --- a/plugins/hockey-scoreboard/base_odds_manager.py +++ b/plugins/hockey-scoreboard/base_odds_manager.py @@ -176,7 +176,11 @@ def get_odds( f"Error decoding JSON response from ESPN API for {cache_key}." ) - return self.cache_manager.get(cache_key) + # Return cached odds on error, but filter out the no_odds sentinel + cached = self.cache_manager.get(cache_key) + if isinstance(cached, dict) and cached.get("no_odds"): + return None + return cached def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: """ diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 58112f8..d0e6269 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -2155,8 +2155,10 @@ def update(self): self._fetch_odds(details) new_live_games.append(details) - # Detect and remove stale games - self._detect_stale_games(new_live_games) + # Detect and remove stale games from persisted list + # (new_live_games has fresh last_seen, so stale check must + # run against the previous self.live_games) + self._detect_stale_games(self.live_games) # Log filtering configuration self.logger.info( diff --git a/plugins/soccer-scoreboard/base_odds_manager.py b/plugins/soccer-scoreboard/base_odds_manager.py index 8875048..f601e94 100644 --- a/plugins/soccer-scoreboard/base_odds_manager.py +++ b/plugins/soccer-scoreboard/base_odds_manager.py @@ -173,7 +173,11 @@ def get_odds( f"Error decoding JSON response from ESPN API for {cache_key}." ) - return self.cache_manager.get(cache_key) + # Return cached odds on error, but filter out the no_odds sentinel + cached = self.cache_manager.get(cache_key) + if isinstance(cached, dict) and cached.get("no_odds"): + return None + return cached def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: """ From 12809e35953aa10fa04374130b34f457d57213d0 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 14 Feb 2026 20:54:29 -0500 Subject: [PATCH 12/15] fix: isinstance dispatch, ClassVar annotations, MiLB date-range optimization - Replace isinstance(self, XxxLiveManager) dispatch in base classes with proper _fetch_data overrides in each Live subclass (OCP compliance) - Add ClassVar annotations to shared mutable class-level state in mlb/ncaa/milb_managers.py to document intentional cross-instance sharing - Optimize MiLB API fetching: use startDate/endDate params for a single request instead of 15 sequential per-date requests - Add debug log when _extract_game_details skips malformed events Co-Authored-By: Claude Opus 4.6 --- plugins/baseball-scoreboard/baseball.py | 3 + plugins/baseball-scoreboard/milb_managers.py | 87 ++++++++++--------- plugins/baseball-scoreboard/mlb_managers.py | 28 +++--- .../ncaa_baseball_managers.py | 30 ++++--- 4 files changed, 79 insertions(+), 69 deletions(-) diff --git a/plugins/baseball-scoreboard/baseball.py b/plugins/baseball-scoreboard/baseball.py index dac8800..ceab921 100644 --- a/plugins/baseball-scoreboard/baseball.py +++ b/plugins/baseball-scoreboard/baseball.py @@ -129,6 +129,9 @@ def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: self._extract_game_details_common(game_event) ) if details is None or home_team is None or away_team is None or status is None: + self.logger.debug( + f"Skipping malformed event (missing common fields): id={game_event.get('id', '?')}" + ) return try: game_status = status["type"]["name"].lower() diff --git a/plugins/baseball-scoreboard/milb_managers.py b/plugins/baseball-scoreboard/milb_managers.py index a261115..7be493c 100644 --- a/plugins/baseball-scoreboard/milb_managers.py +++ b/plugins/baseball-scoreboard/milb_managers.py @@ -1,7 +1,7 @@ import logging from datetime import datetime, timedelta from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, ClassVar, Dict, List, Optional import pytz @@ -23,12 +23,13 @@ class BaseMiLBManager(Baseball): so it works seamlessly with the SportsCore extraction pipeline. """ - # Class variables for warning tracking - _no_data_warning_logged = False - _last_warning_time = 0 - _warning_cooldown = 60 # Only log warnings once per minute - _shared_data = None - _last_shared_update = 0 + # Class variables shared across all MiLB manager instances (Live/Recent/Upcoming) + # so they can share API data and coordinate warning throttling + _no_data_warning_logged: ClassVar[bool] = False + _last_warning_time: ClassVar[float] = 0 + _warning_cooldown: ClassVar[int] = 60 # Only log warnings once per minute + _shared_data: ClassVar[Optional[Dict]] = None + _last_shared_update: ClassVar[float] = 0 def __init__(self, config: Dict[str, Any], display_manager, cache_manager): self.logger = logging.getLogger("MiLB") @@ -204,12 +205,16 @@ def _convert_stats_game_to_espn_event(game: Dict) -> Dict: return event def _fetch_from_mlb_stats_api( - self, dates: List[str], sport_ids: Optional[List[int]] = None + self, start_date: str, end_date: str, sport_ids: Optional[List[int]] = None ) -> Optional[Dict]: """Fetch MiLB games from the MLB Stats API and convert to ESPN format. + Uses the startDate/endDate parameters to fetch the entire range in a + single request instead of one request per date. + Args: - dates: List of date strings in YYYY-MM-DD format + start_date: Start date in YYYY-MM-DD format + end_date: End date in YYYY-MM-DD format sport_ids: MiLB sport IDs to fetch (default: self.milb_sport_ids) Returns: @@ -221,25 +226,28 @@ def _fetch_from_mlb_stats_api( all_events = [] sport_id_str = ",".join(str(s) for s in sport_ids) - for date_str in dates: - url = f"{MLB_STATS_BASE_URL}/schedule?sportId={sport_id_str}&date={date_str}&hydrate=linescore,team" - try: - response = self.session.get( - url, headers=self.headers, timeout=15 - ) - response.raise_for_status() - data = response.json() - - for date_entry in data.get("dates", []): - for game in date_entry.get("games", []): - event = self._convert_stats_game_to_espn_event(game) - all_events.append(event) - - except Exception as e: - self.logger.error( - f"Failed to fetch MiLB data for {date_str}: {e}" - ) - continue + url = ( + f"{MLB_STATS_BASE_URL}/schedule" + f"?sportId={sport_id_str}" + f"&startDate={start_date}&endDate={end_date}" + f"&hydrate=linescore,team" + ) + try: + response = self.session.get( + url, headers=self.headers, timeout=30 + ) + response.raise_for_status() + data = response.json() + + for date_entry in data.get("dates", []): + for game in date_entry.get("games", []): + event = self._convert_stats_game_to_espn_event(game) + all_events.append(event) + + except Exception as e: + self.logger.error( + f"Failed to fetch MiLB data for {start_date} to {end_date}: {e}" + ) if all_events: self.logger.info( @@ -255,7 +263,7 @@ def _fetch_todays_games(self) -> Optional[Dict]: now = datetime.now(pytz.utc) today = now.strftime("%Y-%m-%d") self.logger.debug(f"Fetching today's MiLB games for {today}") - return self._fetch_from_mlb_stats_api([today]) + return self._fetch_from_mlb_stats_api(today, today) def _fetch_milb_api_data(self, use_cache: bool = True) -> Optional[Dict]: """Fetch MiLB season schedule data using MLB Stats API. @@ -297,12 +305,10 @@ def _fetch_milb_api_data(self, use_cache: bool = True) -> Optional[Dict]: return {"events": []} # Fetch recent + upcoming games (7 days back, 7 days ahead) - dates = [ - (now + timedelta(days=d)).strftime("%Y-%m-%d") - for d in range(-7, 8) - ] + start_date = (now + timedelta(days=-7)).strftime("%Y-%m-%d") + end_date = (now + timedelta(days=7)).strftime("%Y-%m-%d") - data = self._fetch_from_mlb_stats_api(dates) + data = self._fetch_from_mlb_stats_api(start_date, end_date) # Cache the result with 4-hour TTL so it refreshes periodically if data and data.get("events"): @@ -314,18 +320,17 @@ def _fetch_milb_api_data(self, use_cache: bool = True) -> Optional[Dict]: return data def _fetch_data(self) -> Optional[Dict]: - """Fetch data using appropriate method based on manager type.""" - if isinstance(self, MiLBLiveManager): - # Live games should fetch only current games - return self._fetch_todays_games() - else: - # Recent and Upcoming managers should use cached schedule data - return self._fetch_milb_api_data(use_cache=True) + """Fetch cached schedule data. Subclasses may override.""" + return self._fetch_milb_api_data(use_cache=True) class MiLBLiveManager(BaseMiLBManager, BaseballLive): """Manager for live MiLB games.""" + def _fetch_data(self) -> Optional[Dict]: + """Live games fetch only today's games, not entire season.""" + return self._fetch_todays_games() + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): super().__init__(config, display_manager, cache_manager) self.logger = logging.getLogger("MiLBLiveManager") diff --git a/plugins/baseball-scoreboard/mlb_managers.py b/plugins/baseball-scoreboard/mlb_managers.py index 4673fec..0ce77c7 100644 --- a/plugins/baseball-scoreboard/mlb_managers.py +++ b/plugins/baseball-scoreboard/mlb_managers.py @@ -1,7 +1,7 @@ import logging from datetime import datetime from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, ClassVar, Dict, Optional import pytz @@ -17,12 +17,13 @@ class BaseMLBManager(Baseball): """Base class for MLB managers with common functionality.""" - # Class variables for warning tracking - _no_data_warning_logged = False - _last_warning_time = 0 - _warning_cooldown = 60 # Only log warnings once per minute - _shared_data = None - _last_shared_update = 0 + # Class variables shared across all MLB manager instances (Live/Recent/Upcoming) + # so they can share API data and coordinate warning throttling + _no_data_warning_logged: ClassVar[bool] = False + _last_warning_time: ClassVar[float] = 0 + _warning_cooldown: ClassVar[int] = 60 # Only log warnings once per minute + _shared_data: ClassVar[Optional[Dict]] = None + _last_shared_update: ClassVar[float] = 0 def __init__(self, config: Dict[str, Any], display_manager, cache_manager): self.logger = logging.getLogger("MLB") @@ -156,18 +157,17 @@ def fetch_callback(result): return None def _fetch_data(self) -> Optional[Dict]: - """Fetch data using shared data mechanism or direct fetch for live.""" - if isinstance(self, MLBLiveManager): - # Live games should fetch only current games, not entire season - return self._fetch_todays_games() - else: - # Recent and Upcoming managers should use cached season data - return self._fetch_mlb_api_data(use_cache=True) + """Fetch cached season data. Subclasses may override.""" + return self._fetch_mlb_api_data(use_cache=True) class MLBLiveManager(BaseMLBManager, BaseballLive): """Manager for live MLB games.""" + def _fetch_data(self) -> Optional[Dict]: + """Live games fetch only today's games, not entire season.""" + return self._fetch_todays_games() + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): super().__init__(config, display_manager, cache_manager) self.logger = logging.getLogger("MLBLiveManager") diff --git a/plugins/baseball-scoreboard/ncaa_baseball_managers.py b/plugins/baseball-scoreboard/ncaa_baseball_managers.py index a7ac680..37d244b 100644 --- a/plugins/baseball-scoreboard/ncaa_baseball_managers.py +++ b/plugins/baseball-scoreboard/ncaa_baseball_managers.py @@ -1,5 +1,5 @@ import logging -from typing import Dict, Any, Optional +from typing import ClassVar, Dict, Any, Optional from datetime import datetime import pytz from baseball import Baseball, BaseballLive, BaseballRecent @@ -13,14 +13,15 @@ class BaseNCAABaseballManager(Baseball): """Base class for NCAA Baseball managers with common functionality.""" - # Class variables for warning tracking - _no_data_warning_logged = False - _last_warning_time = 0 - _warning_cooldown = 60 # Only log warnings once per minute - _shared_data = None - _last_shared_update = 0 - _processed_games_cache = {} # Cache for processed game data - _processed_games_timestamp = 0 + # Class variables shared across all NCAA Baseball manager instances (Live/Recent/Upcoming) + # so they can share API data and coordinate warning throttling + _no_data_warning_logged: ClassVar[bool] = False + _last_warning_time: ClassVar[float] = 0 + _warning_cooldown: ClassVar[int] = 60 # Only log warnings once per minute + _shared_data: ClassVar[Optional[Dict]] = None + _last_shared_update: ClassVar[float] = 0 + _processed_games_cache: ClassVar[Dict] = {} # Cache for processed game data + _processed_games_timestamp: ClassVar[float] = 0 def __init__(self, config: Dict[str, Any], display_manager, cache_manager): self.logger = logging.getLogger("NCAABaseball") @@ -161,16 +162,17 @@ def fetch_callback(result): return None def _fetch_data(self) -> Optional[Dict]: - """Fetch data using shared data mechanism or direct fetch for live.""" - if isinstance(self, NCAABaseballLiveManager): - return self._fetch_todays_games() - else: - return self._fetch_ncaa_baseball_api_data(use_cache=True) + """Fetch cached season data. Subclasses may override.""" + return self._fetch_ncaa_baseball_api_data(use_cache=True) class NCAABaseballLiveManager(BaseNCAABaseballManager, BaseballLive): """Manager for live NCAA Baseball games.""" + def _fetch_data(self) -> Optional[Dict]: + """Live games fetch only today's games, not entire season.""" + return self._fetch_todays_games() + def __init__(self, config: Dict[str, Any], display_manager, cache_manager): super().__init__( config=config, display_manager=display_manager, cache_manager=cache_manager From 8fc1590b69f36116a8606fbd8c25bf3a0e7c49d9 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 14 Feb 2026 21:02:50 -0500 Subject: [PATCH 13/15] fix: lock _detect_stale_games mutation, normalize EN DASH in comments - Wrap _detect_stale_games(self.live_games) in _games_lock to prevent races with display/reader threads (basketball, football, hockey) - Replace EN DASH with standard hyphen in no_odds comment across all 5 base_odds_manager.py files Co-Authored-By: Claude Opus 4.6 --- plugins/baseball-scoreboard/base_odds_manager.py | 2 +- plugins/basketball-scoreboard/base_odds_manager.py | 2 +- plugins/basketball-scoreboard/sports.py | 3 ++- plugins/football-scoreboard/base_odds_manager.py | 2 +- plugins/football-scoreboard/sports.py | 3 ++- plugins/hockey-scoreboard/base_odds_manager.py | 2 +- plugins/hockey-scoreboard/sports.py | 3 ++- plugins/soccer-scoreboard/base_odds_manager.py | 2 +- 8 files changed, 11 insertions(+), 8 deletions(-) diff --git a/plugins/baseball-scoreboard/base_odds_manager.py b/plugins/baseball-scoreboard/base_odds_manager.py index 94ed526..449472b 100644 --- a/plugins/baseball-scoreboard/base_odds_manager.py +++ b/plugins/baseball-scoreboard/base_odds_manager.py @@ -118,7 +118,7 @@ def get_odds( cached_data = self.cache_manager.get(cache_key) if cached_data: - # Filter out the "no_odds" marker – it should not be returned + # Filter out the "no_odds" marker - it should not be returned # as valid odds data. Treat it as a cache miss so a fresh API # call is made once the cache entry expires. if isinstance(cached_data, dict) and cached_data.get("no_odds"): diff --git a/plugins/basketball-scoreboard/base_odds_manager.py b/plugins/basketball-scoreboard/base_odds_manager.py index f457ba0..f58908f 100644 --- a/plugins/basketball-scoreboard/base_odds_manager.py +++ b/plugins/basketball-scoreboard/base_odds_manager.py @@ -118,7 +118,7 @@ def get_odds( cached_data = self.cache_manager.get(cache_key) if cached_data: - # Filter out the "no_odds" marker – it should not be returned + # Filter out the "no_odds" marker - it should not be returned # as valid odds data. Treat it as a cache miss so a fresh API # call is made once the cache entry expires. if isinstance(cached_data, dict) and cached_data.get("no_odds"): diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 9890b7d..2f06ca6 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -2432,7 +2432,8 @@ def update(self): # Detect and remove stale games from persisted list # (new_live_games has fresh last_seen, so stale check must # run against the previous self.live_games) - self._detect_stale_games(self.live_games) + with self._games_lock: + self._detect_stale_games(self.live_games) self.logger.info( f"Live game filtering: {total_events} total events, " diff --git a/plugins/football-scoreboard/base_odds_manager.py b/plugins/football-scoreboard/base_odds_manager.py index 69b65b3..a8e11e6 100644 --- a/plugins/football-scoreboard/base_odds_manager.py +++ b/plugins/football-scoreboard/base_odds_manager.py @@ -118,7 +118,7 @@ def get_odds( cached_data = self.cache_manager.get(cache_key) if cached_data: - # Filter out the "no_odds" marker – it should not be returned + # Filter out the "no_odds" marker - it should not be returned # as valid odds data. Treat it as a cache miss so a fresh API # call is made once the cache entry expires. if isinstance(cached_data, dict) and cached_data.get("no_odds"): diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index bf7707c..ece35f8 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -2334,7 +2334,8 @@ def update(self): # Detect and remove stale games from persisted list # (new_live_games has fresh last_seen, so stale check must # run against the previous self.live_games) - self._detect_stale_games(self.live_games) + with self._games_lock: + self._detect_stale_games(self.live_games) # Log changes or periodically current_time_for_log = ( diff --git a/plugins/hockey-scoreboard/base_odds_manager.py b/plugins/hockey-scoreboard/base_odds_manager.py index 69b65b3..a8e11e6 100644 --- a/plugins/hockey-scoreboard/base_odds_manager.py +++ b/plugins/hockey-scoreboard/base_odds_manager.py @@ -118,7 +118,7 @@ def get_odds( cached_data = self.cache_manager.get(cache_key) if cached_data: - # Filter out the "no_odds" marker – it should not be returned + # Filter out the "no_odds" marker - it should not be returned # as valid odds data. Treat it as a cache miss so a fresh API # call is made once the cache entry expires. if isinstance(cached_data, dict) and cached_data.get("no_odds"): diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index d0e6269..f7fbc2f 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -2158,7 +2158,8 @@ def update(self): # Detect and remove stale games from persisted list # (new_live_games has fresh last_seen, so stale check must # run against the previous self.live_games) - self._detect_stale_games(self.live_games) + with self._games_lock: + self._detect_stale_games(self.live_games) # Log filtering configuration self.logger.info( diff --git a/plugins/soccer-scoreboard/base_odds_manager.py b/plugins/soccer-scoreboard/base_odds_manager.py index f601e94..844bd3e 100644 --- a/plugins/soccer-scoreboard/base_odds_manager.py +++ b/plugins/soccer-scoreboard/base_odds_manager.py @@ -112,7 +112,7 @@ def get_odds( cached_data = self.cache_manager.get(cache_key) if cached_data: - # Filter out the "no_odds" marker – it should not be returned + # Filter out the "no_odds" marker - it should not be returned # as valid odds data. Treat it as a cache miss so a fresh API # call is made once the cache entry expires. if isinstance(cached_data, dict) and cached_data.get("no_odds"): From 7ec04b1ac7d36a477978ecd87b249486024baef9 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 14 Feb 2026 21:18:45 -0500 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20stale=20game=20detection=20in=20so?= =?UTF-8?q?ccer=20and=20baseball=20=E2=80=94=20operate=20on=20self.live=5F?= =?UTF-8?q?games?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soccer and baseball were calling _detect_stale_games(new_live_games) which is ineffective since new_live_games items have fresh last_seen timestamps. Aligned with basketball/football/hockey to check self.live_games under lock. Co-Authored-By: Claude Opus 4.6 --- plugins/baseball-scoreboard/sports.py | 7 +++++-- plugins/soccer-scoreboard/sports.py | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index 3c27b8a..56ac082 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -2335,8 +2335,11 @@ def update(self): f"favorite_teams={self.favorite_teams if self.favorite_teams else '[] (showing all)'}" ) - # Detect and remove stale games - self._detect_stale_games(new_live_games) + # Detect and remove stale games from persisted list + # (new_live_games has fresh last_seen, so stale check must + # run against the previous self.live_games) + with self._games_lock: + self._detect_stale_games(self.live_games) # Log changes or periodically current_time_for_log = ( diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index 39654f2..4208039 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -2379,8 +2379,11 @@ def update(self): self._fetch_odds(details) new_live_games.append(details) - # Detect and remove stale games - self._detect_stale_games(new_live_games) + # Detect and remove stale games from persisted list + # (new_live_games has fresh last_seen, so stale check must + # run against the previous self.live_games) + with self._games_lock: + self._detect_stale_games(self.live_games) self.logger.info( f"Live game filtering: {total_events} total events, " From ba65913fae9151e14b9b2eef3a0e8fe8b0f14468 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 14 Feb 2026 22:31:53 -0500 Subject: [PATCH 15/15] fix: address PR review nitpicks across all 5 scoreboard plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix soccer live data fetch nested inside show_ranking conditional - Remove unused cache_ttl from BaseOddsManager (all 5 plugins) - Use logger.exception() in odds error handlers for tracebacks - Add Optional type hints for None-defaulting parameters - Fix bare return → return None in baseball._extract_game_details - Add maxsplit=1 to count_summary.split for robustness - Remove redundant inning_half/inning_num assignments in baseball - Move BDF set_char_size to one-time lazy init to avoid per-frame overhead - Downgrade cache-hit log level from INFO to DEBUG in MLB/NCAA managers - Add season-year comment for MiLB March boundary logic - Reorder _fetch_data after __init__ in all *LiveManager classes - Prune game_update_timestamps for games no longer in live_games (all 5) - Add duplicate-submission guard for background fetch requests Co-Authored-By: Claude Opus 4.6 --- .../baseball-scoreboard/base_odds_manager.py | 17 +- plugins/baseball-scoreboard/baseball.py | 13 +- plugins/baseball-scoreboard/milb_managers.py | 16 +- plugins/baseball-scoreboard/mlb_managers.py | 20 +- .../ncaa_baseball_managers.py | 20 +- plugins/baseball-scoreboard/sports.py | 7 + .../base_odds_manager.py | 17 +- plugins/basketball-scoreboard/sports.py | 7 + .../football-scoreboard/base_odds_manager.py | 17 +- plugins/football-scoreboard/sports.py | 7 + .../hockey-scoreboard/base_odds_manager.py | 17 +- plugins/hockey-scoreboard/sports.py | 7 + .../soccer-scoreboard/base_odds_manager.py | 17 +- plugins/soccer-scoreboard/sports.py | 431 +++++++++--------- 14 files changed, 325 insertions(+), 288 deletions(-) diff --git a/plugins/baseball-scoreboard/base_odds_manager.py b/plugins/baseball-scoreboard/base_odds_manager.py index 449472b..c56bd06 100644 --- a/plugins/baseball-scoreboard/base_odds_manager.py +++ b/plugins/baseball-scoreboard/base_odds_manager.py @@ -57,7 +57,6 @@ def __init__(self, cache_manager, config_manager=None): # Configuration with defaults self.update_interval = 3600 # 1 hour default self.request_timeout = 30 # 30 seconds default - self.cache_ttl = 1800 # 30 minutes default # Load configuration if available if config_manager: @@ -76,13 +75,11 @@ def _load_configuration(self): "update_interval", self.update_interval ) self.request_timeout = odds_config.get("timeout", self.request_timeout) - self.cache_ttl = odds_config.get("cache_ttl", self.cache_ttl) self.logger.debug( f"BaseOddsManager configuration loaded: " f"update_interval={self.update_interval}s, " - f"timeout={self.request_timeout}s, " - f"cache_ttl={self.cache_ttl}s" + f"timeout={self.request_timeout}s" ) except Exception as e: @@ -93,7 +90,7 @@ def get_odds( sport: str | None, league: str | None, event_id: str, - update_interval_seconds: int = None, + update_interval_seconds: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """ Fetch odds data for a specific game. @@ -171,10 +168,10 @@ def get_odds( return odds_data except requests.exceptions.RequestException as e: - self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}") + self.logger.exception(f"Error fetching odds from ESPN API for {cache_key}") except json.JSONDecodeError: - self.logger.error( - f"Error decoding JSON response from ESPN API for {cache_key}." + self.logger.exception( + f"Error decoding JSON response from ESPN API for {cache_key}" ) # Return cached odds on error, but filter out the no_odds sentinel @@ -248,7 +245,7 @@ def get_multiple_odds( sport: str, league: str, event_ids: List[str], - update_interval_seconds: int = None, + update_interval_seconds: Optional[int] = None, ) -> Dict[str, Dict[str, Any]]: """ Fetch odds data for multiple games. @@ -277,7 +274,7 @@ def get_multiple_odds( return results - def clear_cache(self, sport: str = None, league: str = None, event_id: str = None): + def clear_cache(self, sport: Optional[str] = None, league: Optional[str] = None, event_id: Optional[str] = None): """ Clear odds cache for specific criteria. diff --git a/plugins/baseball-scoreboard/baseball.py b/plugins/baseball-scoreboard/baseball.py index ceab921..dd7b1fd 100644 --- a/plugins/baseball-scoreboard/baseball.py +++ b/plugins/baseball-scoreboard/baseball.py @@ -132,7 +132,7 @@ def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: self.logger.debug( f"Skipping malformed event (missing common fields): id={game_event.get('id', '?')}" ) - return + return None try: game_status = status["type"]["name"].lower() status_state = status["type"]["state"].lower() @@ -249,7 +249,7 @@ def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: if "summary" in situation: try: count_summary = situation["summary"] - balls, strikes = map(int, count_summary.split("-")) + balls, strikes = map(int, count_summary.split("-", 1)) if is_favorite_game: self.logger.debug( f"Using summary count: {count_summary}" @@ -451,16 +451,13 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: text_color = (255, 255, 255) # Draw Inning (Top Center) - inning_half = game["inning_half"] - inning_num = game["inning"] if game["is_final"]: inning_text = "FINAL" else: inning_half_indicator = ( "▲" if game["inning_half"].lower() == "top" else "▼" ) - inning_num = game["inning"] - inning_text = f"{inning_half_indicator}{inning_num}" + inning_text = f"{inning_half_indicator}{game['inning']}" inning_bbox = draw_overlay.textbbox( (0, 0), inning_text, font=self.display_manager.font @@ -605,7 +602,9 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: count_text = f"{balls}-{strikes}" bdf_font = self.display_manager.calendar_font - bdf_font.set_char_size(height=7 * 64) # Set 7px height + if not hasattr(self, '_bdf_font_sized'): + bdf_font.set_char_size(height=7 * 64) # Set 7px height once + self._bdf_font_sized = True count_text_width = self.display_manager.get_text_width(count_text, bdf_font) # Position below the base/out cluster diff --git a/plugins/baseball-scoreboard/milb_managers.py b/plugins/baseball-scoreboard/milb_managers.py index 7be493c..44b2004 100644 --- a/plugins/baseball-scoreboard/milb_managers.py +++ b/plugins/baseball-scoreboard/milb_managers.py @@ -90,14 +90,14 @@ def _convert_stats_game_to_espn_event(game: Dict) -> Dict: status_name = "STATUS_SCHEDULED" # Build inning detail text - current_inning = linescore.get("currentInning", 1) + current_inning = linescore.get("currentInning") or None inning_state = linescore.get("inningState", "") - inning_ordinal = linescore.get("currentInningOrdinal", f"{current_inning}") + inning_ordinal = linescore.get("currentInningOrdinal", f"{current_inning}" if current_inning else "") if abstract_state == "Final": detail_text = "Final" short_detail = "Final" - if current_inning and current_inning != 9: + if current_inning is not None and current_inning != 9: detail_text = f"Final/{current_inning}" short_detail = f"Final/{current_inning}" elif abstract_state == "Live": @@ -248,6 +248,7 @@ def _fetch_from_mlb_stats_api( self.logger.error( f"Failed to fetch MiLB data for {start_date} to {end_date}: {e}" ) + return None if all_events: self.logger.info( @@ -272,6 +273,7 @@ def _fetch_milb_api_data(self, use_cache: bool = True) -> Optional[Dict]: """ now = datetime.now(pytz.utc) season_year = now.year + # MiLB season runs April-September; before April, use previous year if now.month < 4: season_year = now.year - 1 cache_key = f"{self.sport_key}_schedule_{season_year}" @@ -327,10 +329,6 @@ def _fetch_data(self) -> Optional[Dict]: class MiLBLiveManager(BaseMiLBManager, BaseballLive): """Manager for live MiLB games.""" - def _fetch_data(self) -> Optional[Dict]: - """Live games fetch only today's games, not entire season.""" - return self._fetch_todays_games() - def __init__(self, config: Dict[str, Any], display_manager, cache_manager): super().__init__(config, display_manager, cache_manager) self.logger = logging.getLogger("MiLBLiveManager") @@ -367,6 +365,10 @@ def __init__(self, config: Dict[str, Any], display_manager, cache_manager): else: self.logger.info("Initialized MiLBLiveManager in live mode") + def _fetch_data(self) -> Optional[Dict]: + """Live games fetch only today's games, not entire season.""" + return self._fetch_todays_games() + class MiLBRecentManager(BaseMiLBManager, BaseballRecent): """Manager for recently completed MiLB games.""" diff --git a/plugins/baseball-scoreboard/mlb_managers.py b/plugins/baseball-scoreboard/mlb_managers.py index 0ce77c7..a1d5814 100644 --- a/plugins/baseball-scoreboard/mlb_managers.py +++ b/plugins/baseball-scoreboard/mlb_managers.py @@ -69,11 +69,11 @@ def _fetch_mlb_api_data(self, use_cache: bool = True) -> Optional[Dict]: if cached_data: # Validate cached data structure if isinstance(cached_data, dict) and "events" in cached_data: - self.logger.info(f"Using cached schedule for {season_year}") + self.logger.debug(f"Using cached schedule for {season_year}") return cached_data elif isinstance(cached_data, list): # Handle old cache format (list of events) - self.logger.info( + self.logger.debug( f"Using cached schedule for {season_year} (legacy format)" ) return {"events": cached_data} @@ -86,6 +86,14 @@ def _fetch_mlb_api_data(self, use_cache: bool = True) -> Optional[Dict]: # Start background fetch if service is available if self.background_service and self.background_enabled: + # Skip if a fetch is already in progress for this season + if season_year in self.background_fetch_requests: + self.logger.debug( + f"Background fetch already in progress for {season_year}" + ) + partial_data = self._get_weeks_data() + return partial_data + self.logger.info( f"Starting background fetch for {season_year} season schedule..." ) @@ -164,10 +172,6 @@ def _fetch_data(self) -> Optional[Dict]: class MLBLiveManager(BaseMLBManager, BaseballLive): """Manager for live MLB games.""" - def _fetch_data(self) -> Optional[Dict]: - """Live games fetch only today's games, not entire season.""" - return self._fetch_todays_games() - def __init__(self, config: Dict[str, Any], display_manager, cache_manager): super().__init__(config, display_manager, cache_manager) self.logger = logging.getLogger("MLBLiveManager") @@ -204,6 +208,10 @@ def __init__(self, config: Dict[str, Any], display_manager, cache_manager): else: self.logger.info("Initialized MLBLiveManager in live mode") + def _fetch_data(self) -> Optional[Dict]: + """Live games fetch only today's games, not entire season.""" + return self._fetch_todays_games() + class MLBRecentManager(BaseMLBManager, BaseballRecent): """Manager for recently completed MLB games.""" diff --git a/plugins/baseball-scoreboard/ncaa_baseball_managers.py b/plugins/baseball-scoreboard/ncaa_baseball_managers.py index 37d244b..e3d634c 100644 --- a/plugins/baseball-scoreboard/ncaa_baseball_managers.py +++ b/plugins/baseball-scoreboard/ncaa_baseball_managers.py @@ -70,11 +70,11 @@ def _fetch_ncaa_baseball_api_data(self, use_cache: bool = True) -> Optional[Dict if cached_data: # Validate cached data structure if isinstance(cached_data, dict) and "events" in cached_data: - self.logger.info(f"Using cached schedule for {season_year}") + self.logger.debug(f"Using cached schedule for {season_year}") return cached_data elif isinstance(cached_data, list): # Handle old cache format (list of events) - self.logger.info( + self.logger.debug( f"Using cached schedule for {season_year} (legacy format)" ) return {"events": cached_data} @@ -97,6 +97,14 @@ def _fetch_ncaa_baseball_api_data(self, use_cache: bool = True) -> Optional[Dict # Start background fetch if service is available if self.background_service and self.background_enabled: + # Skip if a fetch is already in progress for this season + if season_year in self.background_fetch_requests: + self.logger.debug( + f"Background fetch already in progress for {season_year}" + ) + partial_data = self._get_weeks_data() + return partial_data + self.logger.info( f"Starting background fetch for {season_year} season schedule..." ) @@ -169,10 +177,6 @@ def _fetch_data(self) -> Optional[Dict]: class NCAABaseballLiveManager(BaseNCAABaseballManager, BaseballLive): """Manager for live NCAA Baseball games.""" - def _fetch_data(self) -> Optional[Dict]: - """Live games fetch only today's games, not entire season.""" - return self._fetch_todays_games() - def __init__(self, config: Dict[str, Any], display_manager, cache_manager): super().__init__( config=config, display_manager=display_manager, cache_manager=cache_manager @@ -215,6 +219,10 @@ def __init__(self, config: Dict[str, Any], display_manager, cache_manager): "Initialized NCAABaseballLiveManager in live mode" ) + def _fetch_data(self) -> Optional[Dict]: + """Live games fetch only today's games, not entire season.""" + return self._fetch_todays_games() + class NCAABaseballRecentManager(BaseNCAABaseballManager, BaseballRecent): """Manager for recently completed NCAA Baseball games.""" diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index 56ac082..efb8907 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -2455,6 +2455,13 @@ def update(self): self.current_game = None self.current_game_index = 0 + # Prune game_update_timestamps for games no longer tracked + active_ids = {g["id"] for g in self.live_games} + self.game_update_timestamps = { + gid: ts for gid, ts in self.game_update_timestamps.items() + if gid in active_ids + } + else: # Error fetching data or no events if self.live_games: # Were there games before? diff --git a/plugins/basketball-scoreboard/base_odds_manager.py b/plugins/basketball-scoreboard/base_odds_manager.py index f58908f..f5294b6 100644 --- a/plugins/basketball-scoreboard/base_odds_manager.py +++ b/plugins/basketball-scoreboard/base_odds_manager.py @@ -57,7 +57,6 @@ def __init__(self, cache_manager, config_manager=None): # Configuration with defaults self.update_interval = 3600 # 1 hour default self.request_timeout = 30 # 30 seconds default - self.cache_ttl = 1800 # 30 minutes default # Load configuration if available if config_manager: @@ -76,13 +75,11 @@ def _load_configuration(self): "update_interval", self.update_interval ) self.request_timeout = odds_config.get("timeout", self.request_timeout) - self.cache_ttl = odds_config.get("cache_ttl", self.cache_ttl) self.logger.debug( f"BaseOddsManager configuration loaded: " f"update_interval={self.update_interval}s, " - f"timeout={self.request_timeout}s, " - f"cache_ttl={self.cache_ttl}s" + f"timeout={self.request_timeout}s" ) except Exception as e: @@ -93,7 +90,7 @@ def get_odds( sport: str | None, league: str | None, event_id: str, - update_interval_seconds: int = None, + update_interval_seconds: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """ Fetch odds data for a specific game. @@ -175,10 +172,10 @@ def get_odds( return odds_data except requests.exceptions.RequestException as e: - self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}") + self.logger.exception(f"Error fetching odds from ESPN API for {cache_key}") except json.JSONDecodeError: - self.logger.error( - f"Error decoding JSON response from ESPN API for {cache_key}." + self.logger.exception( + f"Error decoding JSON response from ESPN API for {cache_key}" ) # Return cached odds on error, but filter out the no_odds sentinel @@ -252,7 +249,7 @@ def get_multiple_odds( sport: str, league: str, event_ids: List[str], - update_interval_seconds: int = None, + update_interval_seconds: Optional[int] = None, ) -> Dict[str, Dict[str, Any]]: """ Fetch odds data for multiple games. @@ -281,7 +278,7 @@ def get_multiple_odds( return results - def clear_cache(self, sport: str = None, league: str = None, event_id: str = None): + def clear_cache(self, sport: Optional[str] = None, league: Optional[str] = None, event_id: Optional[str] = None): """ Clear odds cache for specific criteria. diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 2f06ca6..c858c1a 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -2551,6 +2551,13 @@ def update(self): self.current_game = None self.current_game_index = 0 + # Prune game_update_timestamps for games no longer tracked + active_ids = {g["id"] for g in self.live_games} + self.game_update_timestamps = { + gid: ts for gid, ts in self.game_update_timestamps.items() + if gid in active_ids + } + else: # Error fetching data or no events if self.live_games: # Were there games before? diff --git a/plugins/football-scoreboard/base_odds_manager.py b/plugins/football-scoreboard/base_odds_manager.py index a8e11e6..35b1827 100644 --- a/plugins/football-scoreboard/base_odds_manager.py +++ b/plugins/football-scoreboard/base_odds_manager.py @@ -57,7 +57,6 @@ def __init__(self, cache_manager, config_manager=None): # Configuration with defaults self.update_interval = 3600 # 1 hour default self.request_timeout = 30 # 30 seconds default - self.cache_ttl = 1800 # 30 minutes default # Load configuration if available if config_manager: @@ -76,13 +75,11 @@ def _load_configuration(self): "update_interval", self.update_interval ) self.request_timeout = odds_config.get("timeout", self.request_timeout) - self.cache_ttl = odds_config.get("cache_ttl", self.cache_ttl) self.logger.debug( f"BaseOddsManager configuration loaded: " f"update_interval={self.update_interval}s, " - f"timeout={self.request_timeout}s, " - f"cache_ttl={self.cache_ttl}s" + f"timeout={self.request_timeout}s" ) except Exception as e: @@ -93,7 +90,7 @@ def get_odds( sport: str | None, league: str | None, event_id: str, - update_interval_seconds: int = None, + update_interval_seconds: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """ Fetch odds data for a specific game. @@ -170,10 +167,10 @@ def get_odds( return odds_data except requests.exceptions.RequestException as e: - self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}") + self.logger.exception(f"Error fetching odds from ESPN API for {cache_key}") except json.JSONDecodeError: - self.logger.error( - f"Error decoding JSON response from ESPN API for {cache_key}." + self.logger.exception( + f"Error decoding JSON response from ESPN API for {cache_key}" ) # Return cached odds on error, but filter out the no_odds sentinel @@ -247,7 +244,7 @@ def get_multiple_odds( sport: str, league: str, event_ids: List[str], - update_interval_seconds: int = None, + update_interval_seconds: Optional[int] = None, ) -> Dict[str, Dict[str, Any]]: """ Fetch odds data for multiple games. @@ -276,7 +273,7 @@ def get_multiple_odds( return results - def clear_cache(self, sport: str = None, league: str = None, event_id: str = None): + def clear_cache(self, sport: Optional[str] = None, league: Optional[str] = None, event_id: Optional[str] = None): """ Clear odds cache for specific criteria. diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index ece35f8..bd2b301 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -2451,6 +2451,13 @@ def update(self): self.current_game = None self.current_game_index = 0 + # Prune game_update_timestamps for games no longer tracked + active_ids = {g["id"] for g in self.live_games} + self.game_update_timestamps = { + gid: ts for gid, ts in self.game_update_timestamps.items() + if gid in active_ids + } + else: # Error fetching data or no events if self.live_games: # Were there games before? diff --git a/plugins/hockey-scoreboard/base_odds_manager.py b/plugins/hockey-scoreboard/base_odds_manager.py index a8e11e6..35b1827 100644 --- a/plugins/hockey-scoreboard/base_odds_manager.py +++ b/plugins/hockey-scoreboard/base_odds_manager.py @@ -57,7 +57,6 @@ def __init__(self, cache_manager, config_manager=None): # Configuration with defaults self.update_interval = 3600 # 1 hour default self.request_timeout = 30 # 30 seconds default - self.cache_ttl = 1800 # 30 minutes default # Load configuration if available if config_manager: @@ -76,13 +75,11 @@ def _load_configuration(self): "update_interval", self.update_interval ) self.request_timeout = odds_config.get("timeout", self.request_timeout) - self.cache_ttl = odds_config.get("cache_ttl", self.cache_ttl) self.logger.debug( f"BaseOddsManager configuration loaded: " f"update_interval={self.update_interval}s, " - f"timeout={self.request_timeout}s, " - f"cache_ttl={self.cache_ttl}s" + f"timeout={self.request_timeout}s" ) except Exception as e: @@ -93,7 +90,7 @@ def get_odds( sport: str | None, league: str | None, event_id: str, - update_interval_seconds: int = None, + update_interval_seconds: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """ Fetch odds data for a specific game. @@ -170,10 +167,10 @@ def get_odds( return odds_data except requests.exceptions.RequestException as e: - self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}") + self.logger.exception(f"Error fetching odds from ESPN API for {cache_key}") except json.JSONDecodeError: - self.logger.error( - f"Error decoding JSON response from ESPN API for {cache_key}." + self.logger.exception( + f"Error decoding JSON response from ESPN API for {cache_key}" ) # Return cached odds on error, but filter out the no_odds sentinel @@ -247,7 +244,7 @@ def get_multiple_odds( sport: str, league: str, event_ids: List[str], - update_interval_seconds: int = None, + update_interval_seconds: Optional[int] = None, ) -> Dict[str, Dict[str, Any]]: """ Fetch odds data for multiple games. @@ -276,7 +273,7 @@ def get_multiple_odds( return results - def clear_cache(self, sport: str = None, league: str = None, event_id: str = None): + def clear_cache(self, sport: Optional[str] = None, league: Optional[str] = None, event_id: Optional[str] = None): """ Clear odds cache for specific criteria. diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index f7fbc2f..6f6bfdc 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -2281,6 +2281,13 @@ def sort_key(g): self.current_game = None self.current_game_index = 0 + # Prune game_update_timestamps for games no longer tracked + active_ids = {g["id"] for g in self.live_games} + self.game_update_timestamps = { + gid: ts for gid, ts in self.game_update_timestamps.items() + if gid in active_ids + } + else: # Error fetching data or no events if self.live_games: # Were there games before? diff --git a/plugins/soccer-scoreboard/base_odds_manager.py b/plugins/soccer-scoreboard/base_odds_manager.py index 844bd3e..8371c69 100644 --- a/plugins/soccer-scoreboard/base_odds_manager.py +++ b/plugins/soccer-scoreboard/base_odds_manager.py @@ -51,7 +51,6 @@ def __init__(self, cache_manager, config_manager=None): # Configuration with defaults self.update_interval = 3600 # 1 hour default self.request_timeout = 30 # 30 seconds default - self.cache_ttl = 1800 # 30 minutes default # Load configuration if available if config_manager: @@ -70,13 +69,11 @@ def _load_configuration(self): "update_interval", self.update_interval ) self.request_timeout = odds_config.get("timeout", self.request_timeout) - self.cache_ttl = odds_config.get("cache_ttl", self.cache_ttl) self.logger.debug( f"BaseOddsManager configuration loaded: " f"update_interval={self.update_interval}s, " - f"timeout={self.request_timeout}s, " - f"cache_ttl={self.cache_ttl}s" + f"timeout={self.request_timeout}s" ) except Exception as e: @@ -87,7 +84,7 @@ def get_odds( sport: str | None, league: str | None, event_id: str, - update_interval_seconds: int = None, + update_interval_seconds: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """ Fetch odds data for a specific game. @@ -167,10 +164,10 @@ def get_odds( return odds_data except requests.exceptions.RequestException as e: - self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}") + self.logger.exception(f"Error fetching odds from ESPN API for {cache_key}") except json.JSONDecodeError: - self.logger.error( - f"Error decoding JSON response from ESPN API for {cache_key}." + self.logger.exception( + f"Error decoding JSON response from ESPN API for {cache_key}" ) # Return cached odds on error, but filter out the no_odds sentinel @@ -243,7 +240,7 @@ def get_multiple_odds( sport: str, league: str, event_ids: List[str], - update_interval_seconds: int = None, + update_interval_seconds: Optional[int] = None, ) -> Dict[str, Dict[str, Any]]: """ Fetch odds data for multiple games. @@ -272,7 +269,7 @@ def get_multiple_odds( return results - def clear_cache(self, sport: str = None, league: str = None, event_id: str = None): + def clear_cache(self, sport: Optional[str] = None, league: Optional[str] = None, event_id: Optional[str] = None): """ Clear odds cache for specific criteria. diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index 4208039..ab39f07 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -2285,233 +2285,240 @@ def update(self): self._fetch_team_rankings() # Fetch live game data - data = self._fetch_data() - new_live_games = [] - if not data: - self.logger.debug(f"No data returned from _fetch_data() for {self.sport_key}") - elif "events" not in data: - self.logger.debug(f"Data returned but no 'events' key for {self.sport_key}: {list(data.keys()) if isinstance(data, dict) else type(data)}") - elif data and "events" in data: - total_events = len(data["events"]) - self.logger.debug(f"Fetched {total_events} total events from API for {self.sport_key}") - - live_or_halftime_count = 0 - filtered_out_count = 0 - - for game in data["events"]: - details = self._extract_game_details(game) - if details: - # Log game status for debugging - status_state = game.get("competitions", [{}])[0].get("status", {}).get("type", {}).get("state", "unknown") - self.logger.debug( - f"Game {details.get('away_abbr', '?')}@{details.get('home_abbr', '?')}: " - f"state={status_state}, is_live={details.get('is_live')}, " - f"is_halftime={details.get('is_halftime')}, is_final={details.get('is_final')}" - ) + data = self._fetch_data() + new_live_games = [] + if not data: + self.logger.debug(f"No data returned from _fetch_data() for {self.sport_key}") + elif "events" not in data: + self.logger.debug(f"Data returned but no 'events' key for {self.sport_key}: {list(data.keys()) if isinstance(data, dict) else type(data)}") + elif data and "events" in data: + total_events = len(data["events"]) + self.logger.debug(f"Fetched {total_events} total events from API for {self.sport_key}") + + live_or_halftime_count = 0 + filtered_out_count = 0 + + for game in data["events"]: + details = self._extract_game_details(game) + if details: + # Log game status for debugging + status_state = game.get("competitions", [{}])[0].get("status", {}).get("type", {}).get("state", "unknown") + self.logger.debug( + f"Game {details.get('away_abbr', '?')}@{details.get('home_abbr', '?')}: " + f"state={status_state}, is_live={details.get('is_live')}, " + f"is_halftime={details.get('is_halftime')}, is_final={details.get('is_final')}" + ) - # Filter out final games and games that appear to be over - if details.get("is_final", False): - continue + # Filter out final games and games that appear to be over + if details.get("is_final", False): + continue - if self._is_game_really_over(details): - self.logger.info( - f"Skipping game that appears final: {details.get('away_abbr')}@{details.get('home_abbr')} " - f"(clock={details.get('clock')}, period={details.get('period')}, period_text={details.get('period_text')})" + if self._is_game_really_over(details): + self.logger.info( + f"Skipping game that appears final: {details.get('away_abbr')}@{details.get('home_abbr')} " + f"(clock={details.get('clock')}, period={details.get('period')}, period_text={details.get('period_text')})" + ) + continue + + if details["is_live"] or details["is_halftime"]: + live_or_halftime_count += 1 + + # Filtering logic matching SportsUpcoming: + # - If show_all_live = True → show all games + # - If show_favorite_teams_only = False → show all games + # - If show_favorite_teams_only = True but favorite_teams is empty → show all games (fallback) + # - If show_favorite_teams_only = True and favorite_teams has teams → only show games with those teams + if self.show_all_live: + # Always show all live games if show_all_live is enabled + should_include = True + elif not self.show_favorite_teams_only: + # If favorite teams filtering is disabled, show all games + should_include = True + elif not self.favorite_teams: + # If favorite teams filtering is enabled but no favorites are configured, + # show all games (same behavior as SportsUpcoming) + should_include = True + else: + # Favorite teams filtering is enabled AND favorites are configured + # Only show games involving favorite teams + should_include = ( + details["home_abbr"] in self.favorite_teams + or details["away_abbr"] in self.favorite_teams ) - continue - - if details["is_live"] or details["is_halftime"]: - live_or_halftime_count += 1 - - # Filtering logic matching SportsUpcoming: - # - If show_all_live = True → show all games - # - If show_favorite_teams_only = False → show all games - # - If show_favorite_teams_only = True but favorite_teams is empty → show all games (fallback) - # - If show_favorite_teams_only = True and favorite_teams has teams → only show games with those teams - if self.show_all_live: - # Always show all live games if show_all_live is enabled - should_include = True - elif not self.show_favorite_teams_only: - # If favorite teams filtering is disabled, show all games - should_include = True - elif not self.favorite_teams: - # If favorite teams filtering is enabled but no favorites are configured, - # show all games (same behavior as SportsUpcoming) - should_include = True - else: - # Favorite teams filtering is enabled AND favorites are configured - # Only show games involving favorite teams - should_include = ( - details["home_abbr"] in self.favorite_teams - or details["away_abbr"] in self.favorite_teams - ) - - if not should_include: - filtered_out_count += 1 - self.logger.debug( - f"Filtered out live game {details.get('away_abbr')}@{details.get('home_abbr')}: " - f"show_all_live={self.show_all_live}, " - f"show_favorite_teams_only={self.show_favorite_teams_only}, " - f"favorite_teams={self.favorite_teams}" - ) - - if should_include: - # Track game timestamps for stale detection - game_id = details.get("id") - if game_id: - current_clock = details.get("clock", "") - current_score = f"{details.get('away_score', '0')}-{details.get('home_score', '0')}" - - if game_id not in self.game_update_timestamps: - self.game_update_timestamps[game_id] = {} - - timestamps = self.game_update_timestamps[game_id] - timestamps["last_seen"] = time.time() - - if timestamps.get("last_clock") != current_clock: - timestamps["last_clock"] = current_clock - timestamps["clock_changed_at"] = time.time() - if timestamps.get("last_score") != current_score: - timestamps["last_score"] = current_score - timestamps["score_changed_at"] = time.time() - - if self.show_odds: - self._fetch_odds(details) - new_live_games.append(details) - - # Detect and remove stale games from persisted list - # (new_live_games has fresh last_seen, so stale check must - # run against the previous self.live_games) - with self._games_lock: - self._detect_stale_games(self.live_games) + + if not should_include: + filtered_out_count += 1 + self.logger.debug( + f"Filtered out live game {details.get('away_abbr')}@{details.get('home_abbr')}: " + f"show_all_live={self.show_all_live}, " + f"show_favorite_teams_only={self.show_favorite_teams_only}, " + f"favorite_teams={self.favorite_teams}" + ) + + if should_include: + # Track game timestamps for stale detection + game_id = details.get("id") + if game_id: + current_clock = details.get("clock", "") + current_score = f"{details.get('away_score', '0')}-{details.get('home_score', '0')}" + + if game_id not in self.game_update_timestamps: + self.game_update_timestamps[game_id] = {} + + timestamps = self.game_update_timestamps[game_id] + timestamps["last_seen"] = time.time() + + if timestamps.get("last_clock") != current_clock: + timestamps["last_clock"] = current_clock + timestamps["clock_changed_at"] = time.time() + if timestamps.get("last_score") != current_score: + timestamps["last_score"] = current_score + timestamps["score_changed_at"] = time.time() + + if self.show_odds: + self._fetch_odds(details) + new_live_games.append(details) + + # Detect and remove stale games from persisted list + # (new_live_games has fresh last_seen, so stale check must + # run against the previous self.live_games) + with self._games_lock: + self._detect_stale_games(self.live_games) - self.logger.info( - f"Live game filtering: {total_events} total events, " - f"{live_or_halftime_count} live/halftime, " - f"{filtered_out_count} filtered out, " - f"{len(new_live_games)} included | " - f"show_all_live={self.show_all_live}, " - f"show_favorite_teams_only={self.show_favorite_teams_only}, " - f"favorite_teams={self.favorite_teams if self.favorite_teams else '[] (showing all)'}" - ) - # Log changes or periodically - current_time_for_log = ( - time.time() - ) # Use a consistent time for logging comparison - should_log = ( - current_time_for_log - self.last_log_time >= self.log_interval - or len(new_live_games) != len(self.live_games) - or any( - g1["id"] != g2.get("id") - for g1, g2 in zip(self.live_games, new_live_games) - ) # Check if game IDs changed - or ( - not self.live_games and new_live_games - ) # Log if games appeared - ) + self.logger.info( + f"Live game filtering: {total_events} total events, " + f"{live_or_halftime_count} live/halftime, " + f"{filtered_out_count} filtered out, " + f"{len(new_live_games)} included | " + f"show_all_live={self.show_all_live}, " + f"show_favorite_teams_only={self.show_favorite_teams_only}, " + f"favorite_teams={self.favorite_teams if self.favorite_teams else '[] (showing all)'}" + ) + # Log changes or periodically + current_time_for_log = ( + time.time() + ) # Use a consistent time for logging comparison + should_log = ( + current_time_for_log - self.last_log_time >= self.log_interval + or len(new_live_games) != len(self.live_games) + or any( + g1["id"] != g2.get("id") + for g1, g2 in zip(self.live_games, new_live_games) + ) # Check if game IDs changed + or ( + not self.live_games and new_live_games + ) # Log if games appeared + ) - if should_log: - if new_live_games: - filter_text = ( - "favorite teams" - if self.show_favorite_teams_only or self.show_all_live - else "all teams" - ) + if should_log: + if new_live_games: + filter_text = ( + "favorite teams" + if self.show_favorite_teams_only or self.show_all_live + else "all teams" + ) + self.logger.info( + f"Found {len(new_live_games)} live/halftime games for {filter_text}." + ) + for ( + game_info + ) in new_live_games: # Renamed game to game_info self.logger.info( - f"Found {len(new_live_games)} live/halftime games for {filter_text}." + f" - {game_info['away_abbr']}@{game_info['home_abbr']} ({game_info.get('status_text', 'N/A')})" ) - for ( - game_info - ) in new_live_games: # Renamed game to game_info - self.logger.info( - f" - {game_info['away_abbr']}@{game_info['home_abbr']} ({game_info.get('status_text', 'N/A')})" + else: + filter_text = ( + "favorite teams" + if self.show_favorite_teams_only or self.show_all_live + else "criteria" + ) + self.logger.info( + f"No live/halftime games found for {filter_text}." + ) + self.last_log_time = current_time_for_log + + # Update game list and current game (protected by lock for thread safety) + with self._games_lock: + if new_live_games: + # Check if the games themselves changed, not just scores/time + new_game_ids = {g["id"] for g in new_live_games} + current_game_ids = {g["id"] for g in self.live_games} + + if new_game_ids != current_game_ids: + self.live_games = sorted( + new_live_games, + key=lambda g: g.get("start_time_utc") + or datetime.now(timezone.utc), + ) # Sort by start time + # Reset index if current game is gone or list is new + if ( + not self.current_game + or self.current_game["id"] not in new_game_ids + ): + self.current_game_index = 0 + self.current_game = ( + self.live_games[0] if self.live_games else None ) - else: - filter_text = ( - "favorite teams" - if self.show_favorite_teams_only or self.show_all_live - else "criteria" - ) - self.logger.info( - f"No live/halftime games found for {filter_text}." - ) - self.last_log_time = current_time_for_log - - # Update game list and current game (protected by lock for thread safety) - with self._games_lock: - if new_live_games: - # Check if the games themselves changed, not just scores/time - new_game_ids = {g["id"] for g in new_live_games} - current_game_ids = {g["id"] for g in self.live_games} - - if new_game_ids != current_game_ids: - self.live_games = sorted( - new_live_games, - key=lambda g: g.get("start_time_utc") - or datetime.now(timezone.utc), - ) # Sort by start time - # Reset index if current game is gone or list is new - if ( - not self.current_game - or self.current_game["id"] not in new_game_ids - ): - self.current_game_index = 0 - self.current_game = ( - self.live_games[0] if self.live_games else None + self.last_game_switch = current_time + else: + # Find current game's new index if it still exists + try: + self.current_game_index = next( + i + for i, g in enumerate(self.live_games) + if g["id"] == self.current_game["id"] ) + self.current_game = self.live_games[ + self.current_game_index + ] # Update current_game with fresh data + except ( + StopIteration + ): # Should not happen if check above passed, but safety first + self.current_game_index = 0 + self.current_game = self.live_games[0] self.last_game_switch = current_time - else: - # Find current game's new index if it still exists - try: - self.current_game_index = next( - i - for i, g in enumerate(self.live_games) - if g["id"] == self.current_game["id"] - ) - self.current_game = self.live_games[ - self.current_game_index - ] # Update current_game with fresh data - except ( - StopIteration - ): # Should not happen if check above passed, but safety first - self.current_game_index = 0 - self.current_game = self.live_games[0] - self.last_game_switch = current_time - else: - # Just update the data for the existing games - temp_game_dict = {g["id"]: g for g in new_live_games} - self.live_games = [ - temp_game_dict.get(g["id"], g) for g in self.live_games - ] # Update in place - if self.current_game: - self.current_game = temp_game_dict.get( - self.current_game["id"], self.current_game - ) + else: + # Just update the data for the existing games + temp_game_dict = {g["id"]: g for g in new_live_games} + self.live_games = [ + temp_game_dict.get(g["id"], g) for g in self.live_games + ] # Update in place + if self.current_game: + self.current_game = temp_game_dict.get( + self.current_game["id"], self.current_game + ) - # Display update handled by main loop based on interval + # Display update handled by main loop based on interval - else: - # No live games found - if self.live_games: # Were there games before? - self.logger.info( - "Live games previously showing have ended or are no longer live." - ) # Changed log prefix - self.live_games = [] - self.current_game = None - self.current_game_index = 0 + else: + # No live games found + if self.live_games: # Were there games before? + self.logger.info( + "Live games previously showing have ended or are no longer live." + ) # Changed log prefix + self.live_games = [] + self.current_game = None + self.current_game_index = 0 + + # Prune game_update_timestamps for games no longer tracked + active_ids = {g["id"] for g in self.live_games} + self.game_update_timestamps = { + gid: ts for gid, ts in self.game_update_timestamps.items() + if gid in active_ids + } + else: + # Error fetching data or no events + if self.live_games: # Were there games before? + self.logger.warning( + "Could not fetch update; keeping existing live game data for now." + ) # Changed log prefix else: - # Error fetching data or no events - if self.live_games: # Were there games before? - self.logger.warning( - "Could not fetch update; keeping existing live game data for now." - ) # Changed log prefix - else: - self.logger.warning( - "Could not fetch data and no existing live games." - ) # Changed log prefix - self.current_game = None # Clear current game if fetch fails and no games were active + self.logger.warning( + "Could not fetch data and no existing live games." + ) # Changed log prefix + self.current_game = None # Clear current game if fetch fails and no games were active # Handle game switching (protected by lock for thread safety) with self._games_lock: