diff --git a/src/base_classes/baseball.py b/src/base_classes/baseball.py index 10c0b9af..749786a2 100644 --- a/src/base_classes/baseball.py +++ b/src/base_classes/baseball.py @@ -5,142 +5,676 @@ This module provides baseball-specific base classes that extend the core sports with baseball-specific logic for innings, outs, bases, strikes, balls, etc. """ -from typing import Dict, Any, Optional, List -from src.base_classes.sports import SportsCore -from src.base_classes.api_extractors import ESPNBaseballExtractor -from src.base_classes.data_sources import ESPNDataSource, MLBAPIDataSource import logging +import random +import time +from typing import Any, Dict, Optional + +from PIL import Image, ImageDraw + +from src.base_classes.data_sources import ESPNDataSource +from src.base_classes.sports import SportsCore, SportsLive + 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): + 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) - - # Initialize baseball-specific architecture components - self.api_extractor = ESPNBaseballExtractor(logger) - - # Choose data source based on sport (MLB uses MLB API, others use ESPN) - if sport_key == 'mlb': - self.data_source = MLBAPIDataSource(logger) - else: - self.data_source = ESPNDataSource(logger) - # 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.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', '') + inning = game.get("inning", "") if inning: display_parts.append(f"Inning: {inning}") - + # Outs information if self.show_outs: - outs = game.get('outs', 0) + 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', '') + 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) + 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', '') + 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) + 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': + 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', '') - + 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): + elif game.get("is_final", False): return "Final" - elif game.get('is_upcoming', False): + 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 "" - -class BaseballLive(Baseball): - """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) - self.logger.info(f"{sport_key.upper()} Live Manager initialized") - - def _should_show_baseball_game(self, game: Dict) -> bool: - """Determine if a baseball game should be shown.""" + def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: + """Extract relevant game details from ESPN NCAA FB 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: - # Only show live games - if not self._is_baseball_game_live(game): - return False - - # Check if game meets display criteria - return self._should_show_game(game) - + # print(status["type"]["state"]) + # exit() + 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', '')}" + ) + + # 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"], + } + ) + + # 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: - self.logger.error(f"Error checking if baseball game should be shown: {e}") - return False + # 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 +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"]: + # self.current_game["bases_occupied"] = [ + # random.choice([True, False]) for _ in range(3) + # ] + # self.current_game["balls"] = random.choice([1, 2, 3]) + # self.current_game["strikes"] = random.choice([1, 2]) + # self.current_game["outs"] = random.choice([1, 2]) + 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 NCAA FB game.""" # Updated docstring + 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')}" + ) # Changed log prefix + # 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 + ) # adjusted from 18 # Adjust position as needed + home_y = center_y - (home_logo.height // 2) + main_img.paste(home_logo, (home_x, home_y), home_logo) + + away_x = -10 # adjusted from 18 # Adjust position as needed + 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 + # draw_overlay.text((inning_x, inning_y), inning_text, fill=(255, 255, 255), font=self.display_manager.font) + 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 (moved up 3 pixels) + + # 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 + # self.display_manager._draw_bdf_text(count_text, count_x, count_y, text_color, font=bdf_font) + # Use _draw_text_with_outline for count text + # self._draw_text_with_outline(draw, count_text, (count_x, count_y), bdf_font, fill=text_color) + + # Draw Balls-Strikes Count with outline using BDF font + # Define outline color (consistent with _draw_text_with_outline default) + 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, + ) # Use a specific name for score text color + + # 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) + # Get font height (approximate or precise) + 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 Football game: {e}", exc_info=True + ) # Changed log prefix diff --git a/src/base_classes/football.py b/src/base_classes/football.py index 01f4cc55..3aca6c9a 100644 --- a/src/base_classes/football.py +++ b/src/base_classes/football.py @@ -5,20 +5,14 @@ from datetime import datetime, timezone, timedelta import logging from PIL import Image, ImageDraw, ImageFont import time -import pytz -from src.base_classes.sports import SportsCore -from src.base_classes.api_extractors import ESPNFootballExtractor from src.base_classes.data_sources import ESPNDataSource -import requests +from src.base_classes.sports import SportsCore, SportsLive class Football(SportsCore): """Base class for football sports with common functionality.""" def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): super().__init__(config, display_manager, cache_manager, logger, sport_key) - - # Initialize football-specific architecture components - self.api_extractor = ESPNFootballExtractor(logger) self.data_source = ESPNDataSource(logger) self.sport = "football" @@ -82,12 +76,12 @@ class Football(SportsCore): period = status.get("period", 0) period_text = "" if status["type"]["state"] == "in": - if period == 0: period_text = "Start" # Before kickoff - elif period == 1: period_text = "Q1" - elif period == 2: period_text = "Q2" - elif period == 3: period_text = "Q3" # Fixed: period 3 is 3rd quarter, not halftime - elif period == 4: period_text = "Q4" - elif period > 4: period_text = "OT" # OT starts after Q4 + if period == 0: + period_text = "Start" # Before kickoff + elif period >= 1 and period <= 4: + period_text = f"Q{period}" # OT starts after Q4 + elif period > 4: + period_text = f"OT{period - 4}" # OT starts after Q4 elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state period_text = "HALF" elif status["type"]["state"] == "post": @@ -122,179 +116,48 @@ class Football(SportsCore): logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True) return None -class FootballLive(Football): +class FootballLive(Football, SportsLive): def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, 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 - self.last_update = 0 - self.live_games = [] - self.current_game_index = 0 - self.last_game_switch = 0 - 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 - def update(self): - """Update live game data and handle game switching.""" - if not self.is_enabled: - return + def _test_mode_update(self): + if self.current_game and self.current_game["is_live"]: + try: + minutes, seconds = map(int, self.current_game["clock"].split(':')) + seconds -= 1 + if seconds < 0: + seconds = 59 + minutes -= 1 + if minutes < 0: + # Simulate end of quarter/game + if self.current_game["period"] < 4: # Q4 is period 4 + self.current_game["period"] += 1 + # Update period_text based on new period + if self.current_game["period"] == 1: self.current_game["period_text"] = "Q1" + elif self.current_game["period"] == 2: self.current_game["period_text"] = "Q2" + elif self.current_game["period"] == 3: self.current_game["period_text"] = "Q3" + elif self.current_game["period"] == 4: self.current_game["period_text"] = "Q4" + # Reset clock for next quarter (e.g., 15:00) + minutes, seconds = 15, 0 + else: + # Simulate game end + self.current_game["is_live"] = False + self.current_game["is_final"] = True + self.current_game["period_text"] = "Final" + minutes, seconds = 0, 0 + self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" + # Simulate down change occasionally + if seconds % 15 == 0: + self.current_game["down_distance_text"] = f"{['1st','2nd','3rd','4th'][seconds % 4]} & {seconds % 10 + 1}" + self.current_game["status_text"] = f"{self.current_game['period_text']} {self.current_game['clock']}" - # 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() + # Display update handled by main loop or explicit call if needed immediately + # self.display(force_clear=True) # Only if immediate update is desired here - # 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 = getattr(self, 'live_games', []) - _test_mode_attr = getattr(self, 'test_mode', False) # test_mode is often from a base class or config - _no_data_interval_attr = getattr(self, 'no_data_interval', 300) # Default similar to NFLLiveManager - _update_interval_attr = getattr(self, 'update_interval', 15) # Default similar to NFLLiveManager - - interval = _no_data_interval_attr if not _live_games_attr and not _test_mode_attr else _update_interval_attr + except ValueError: + self.logger.warning("Test mode: Could not parse clock") # Changed log prefix + # No actual display call here, let main loop handle it - # 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() - - if self.test_mode: - # Simulate clock running down in test mode - if self.current_game and self.current_game["is_live"]: - try: - minutes, seconds = map(int, self.current_game["clock"].split(':')) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - # Simulate end of quarter/game - if self.current_game["period"] < 4: # Q4 is period 4 - self.current_game["period"] += 1 - # Update period_text based on new period - if self.current_game["period"] == 1: self.current_game["period_text"] = "Q1" - elif self.current_game["period"] == 2: self.current_game["period_text"] = "Q2" - elif self.current_game["period"] == 3: self.current_game["period_text"] = "Q3" - elif self.current_game["period"] == 4: self.current_game["period_text"] = "Q4" - # Reset clock for next quarter (e.g., 15:00) - minutes, seconds = 15, 0 - else: - # Simulate game end - self.current_game["is_live"] = False - self.current_game["is_final"] = True - self.current_game["period_text"] = "Final" - minutes, seconds = 0, 0 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Simulate down change occasionally - if seconds % 15 == 0: - self.current_game["down_distance_text"] = f"{['1st','2nd','3rd','4th'][seconds % 4]} & {seconds % 10 + 1}" - self.current_game["status_text"] = f"{self.current_game['period_text']} {self.current_game['clock']}" - - # Display update handled by main loop or explicit call if needed immediately - # self.display(force_clear=True) # Only if immediate update is desired here - - except ValueError: - self.logger.warning("Test mode: Could not parse clock") # Changed log prefix - # No actual display call here, let main loop handle it - else: - # Fetch live game data - data = self._fetch_data() - new_live_games = [] - if data and "events" in data: - for game in data["events"]: - details = self._extract_game_details(game) - if details and (details["is_live"] or details["is_halftime"]): - # If show_favorite_teams_only is true, only add if it's a favorite. - # Otherwise, add all games. - if self.show_favorite_teams_only: - if details["home_abbr"] in self.favorite_teams or details["away_abbr"] in self.favorite_teams: - new_live_games.append(details) - else: - new_live_games.append(details) - for game in new_live_games: - if self.show_odds: - self._fetch_odds(game) - # 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)) or # Check if game IDs changed - (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 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 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 - 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: - # 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 - - 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) - if not self.test_mode and len(self.live_games) > 1 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 def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring diff --git a/src/base_classes/hockey.py b/src/base_classes/hockey.py index eb600aca..203d2566 100644 --- a/src/base_classes/hockey.py +++ b/src/base_classes/hockey.py @@ -1,30 +1,37 @@ -from typing import Dict, Any, Optional -from src.display_manager import DisplayManager -from src.cache_manager import CacheManager -from datetime import datetime, timezone import logging -from PIL import Image, ImageDraw, ImageFont import time -from src.base_classes.sports import SportsCore -from src.base_classes.api_extractors import ESPNHockeyExtractor +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from PIL import Image, ImageDraw, ImageFont + from src.base_classes.data_sources import ESPNDataSource +from src.base_classes.sports import SportsCore, SportsLive +from src.cache_manager import CacheManager +from src.display_manager import DisplayManager + class Hockey(SportsCore): """Base class for hockey sports with common functionality.""" - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + logger: logging.Logger, + sport_key: str, + ): super().__init__(config, display_manager, cache_manager, logger, sport_key) - - # Initialize hockey-specific architecture components - self.api_extractor = ESPNHockeyExtractor(logger) self.data_source = ESPNDataSource(logger) self.sport = "hockey" - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: """Extract relevant game details from ESPN NCAA FB API response.""" # --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES --- - details, home_team, away_team, status, situation = self._extract_game_details_common(game_event) + 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: @@ -32,7 +39,46 @@ class Hockey(SportsCore): status = competition["status"] powerplay = False penalties = "" - shots_on_goal = {"home": 0, "away": 0} + home_team_saves = next( + ( + int(c["displayValue"]) + for c in home_team["statistics"] + if c.get("name") == "saves" + ), + 0, + ) + home_team_saves_per = next( + ( + float(c["displayValue"]) + for c in home_team["statistics"] + if c.get("name") == "savePct" + ), + 0.0, + ) + away_team_saves = next( + ( + int(c["displayValue"]) + for c in away_team["statistics"] + if c.get("name") == "saves" + ), + 0, + ) + away_team_saves_per = next( + ( + float(c["displayValue"]) + for c in away_team["statistics"] + if c.get("name") == "savePct" + ), + 0.0, + ) + + home_shots = 0 + away_shots = 0 + + if home_team_saves_per > 0: + away_shots = round(home_team_saves / home_team_saves_per) + if away_team_saves_per > 0: + home_shots = round(away_team_saves / away_team_saves_per) if situation and status["type"]["state"] == "in": # Detect scoring events from status detail @@ -40,222 +86,192 @@ class Hockey(SportsCore): status_short = status["type"].get("shortDetail", "").lower() powerplay = situation.get("isPowerPlay", False) penalties = situation.get("penalties", "") - shots_on_goal = { - "home": situation.get("homeShots", 0), - "away": situation.get("awayShots", 0) - } # Format period/quarter period = status.get("period", 0) period_text = "" if status["type"]["state"] == "in": - if period == 0: period_text = "Start" # Before kickoff - elif period == 1: period_text = "P1" - elif period == 2: period_text = "P2" - elif period == 3: period_text = "P3" # Fixed: period 3 is 3rd quarter, not halftime - elif period > 3: period_text = f"OT {period - 3}" # OT starts after P3 + if period == 0: + period_text = "Start" # Before kickoff + elif period >= 1 and period <= 3: + period_text = f"P{period}" # OT starts after Q4 + elif period > 3: + period_text = f"OT{period - 3}" # OT starts after Q4 elif status["type"]["state"] == "post": - if period > 3 : - period_text = "Final/OT" - else: - period_text = "Final" + if period > 3: + period_text = "Final/OT" + else: + period_text = "Final" elif status["type"]["state"] == "pre": - period_text = details.get("game_time", "") # Show time for upcoming + period_text = details.get("game_time", "") # Show time for upcoming - details.update({ - "period": period, - "period_text": period_text, # Formatted quarter/status - "clock": status.get("displayClock", "0:00"), - "power_play": powerplay, - "penalties": penalties, - "shots_on_goal": shots_on_goal - }) + details.update( + { + "period": period, + "period_text": period_text, # Formatted quarter/status + "clock": status.get("displayClock", "0:00"), + "power_play": powerplay, + "penalties": penalties, + "home_shots": home_shots, + "away_shots": away_shots, + } + ) # 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 + 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']}") + 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 - logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True) + self.logger.error( + f"Error extracting game details: {e} from event: {game_event.get('id')}", + exc_info=True, + ) return None -class HockeyLive(Hockey): - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): + +class HockeyLive(Hockey, SportsLive): + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + 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 - self.last_update = 0 - self.live_games = [] - self.current_game_index = 0 - self.last_game_switch = 0 - 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 - def update(self): - """Update live game data.""" - if not self.is_enabled: return - current_time = time.time() - interval = self.no_data_interval if not self.live_games else self.update_interval - - if current_time - self.last_update >= interval: - self.last_update = current_time - - if self.show_ranking: - self._fetch_team_rankings() - - if self.test_mode: - # For testing, we'll just update the clock to show it's working - if self.current_game: - minutes = int(self.current_game["clock"].split(":")[0]) - seconds = int(self.current_game["clock"].split(":")[1]) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - minutes = 19 - if self.current_game["period"] < 3: - self.current_game["period"] += 1 - else: - self.current_game["period"] = 1 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Always update display in test mode - self.display(force_clear=True) - else: - # Fetch live game data from ESPN API - data = self._fetch_data() - if data and "events" in data: - # Find all live games involving favorite teams - new_live_games = [] - for event in data["events"]: - details = self._extract_game_details(event) - if details and details["is_live"]: - self._fetch_odds(details) - new_live_games.append(details) - - # Filter for favorite teams only if the config is set - if self.show_favorite_teams_only: - new_live_games = [game for game in new_live_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - - # Only log if there's a change in games or enough time has passed - should_log = ( - current_time - self.last_log_time >= self.log_interval or - len(new_live_games) != len(self.live_games) or - not self.live_games # Log if we had no games before - ) - - if should_log: - if new_live_games: - filter_text = "favorite teams" if self.show_favorite_teams_only else "all teams" - self.logger.info(f"[NCAAMH] Found {len(new_live_games)} live games involving {filter_text}") - for game in new_live_games: - self.logger.info(f"[NCAAMH] Live game: {game['away_abbr']} vs {game['home_abbr']} - Period {game['period']}, {game['clock']}") - else: - filter_text = "favorite teams" if self.show_favorite_teams_only else "criteria" - self.logger.info(f"[NCAAMH] No live games found matching {filter_text}") - self.last_log_time = current_time - - if new_live_games: - # Update the current game with the latest data - for new_game in new_live_games: - if self.current_game and ( - (new_game["home_abbr"] == self.current_game["home_abbr"] and - new_game["away_abbr"] == self.current_game["away_abbr"]) or - (new_game["home_abbr"] == self.current_game["away_abbr"] and - new_game["away_abbr"] == self.current_game["home_abbr"]) - ): - self.current_game = new_game - break - - # Only update the games list if we have new games - if not self.live_games or set(game["away_abbr"] + game["home_abbr"] for game in new_live_games) != set(game["away_abbr"] + game["home_abbr"] for game in self.live_games): - self.live_games = new_live_games - # If we don't have a current game or it's not in the new list, start from the beginning - if not self.current_game or self.current_game not in self.live_games: - self.current_game_index = 0 - self.current_game = self.live_games[0] - self.last_game_switch = current_time - - # Update display if data changed, limit rate - if current_time - self.last_display_update >= 1.0: - # self.display(force_clear=True) # REMOVED: DisplayController handles this - self.last_display_update = current_time - + def _test_mode_update(self): + if self.current_game and self.current_game["is_live"]: + # For testing, we'll just update the clock to show it's working + minutes = int(self.current_game["clock"].split(":")[0]) + seconds = int(self.current_game["clock"].split(":")[1]) + seconds -= 1 + if seconds < 0: + seconds = 59 + minutes -= 1 + if minutes < 0: + minutes = 19 + if self.current_game["period"] < 3: + self.current_game["period"] += 1 else: - # No live games found - self.live_games = [] - self.current_game = None - - # Check if it's time to switch games - if len(self.live_games) > 1 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.display(force_clear=True) # REMOVED: DisplayController handles this - self.last_display_update = current_time # Track time for potential display update + self.current_game["period"] = 1 + self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" + # Always update display in test mode def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring + """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring 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")) + 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')}") # Changed log prefix + self.logger.error( + f"Failed to load logos for live game: {game.get('id')}" + ) # Changed log prefix # 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)) + 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 #adjusted from 18 # Adjust position as needed + home_x = ( + self.display_width - home_logo.width + 10 + ) # adjusted from 18 # Adjust position as needed home_y = center_y - (home_logo.height // 2) main_img.paste(home_logo, (home_x, home_y), home_logo) - away_x = -10 #adjusted from 18 # Adjust position as needed + away_x = -10 # adjusted from 18 # Adjust position as needed away_y = center_y - (away_logo.height // 2) 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 + # Period/Quarter and Clock (Top center) + period_clock_text = ( + f"{game.get('period_text', '')} {game.get('clock', '')}".strip() + ) + if game.get("is_halftime"): + period_clock_text = "Halftime" # Override for halftime + + status_width = draw_overlay.textlength( + period_clock_text, font=self.fonts["time"] + ) + status_x = (self.display_width - status_width) // 2 + status_y = 1 # Position at top + self._draw_text_with_outline( + draw_overlay, + period_clock_text, + (status_x, status_y), + self.fonts["time"], + ) + # Scores (centered, slightly above bottom) 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_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 #centered #from 14 # Position score higher - self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) + score_y = ( + self.display_height // 2 + ) - 3 # centered #from 14 # Position score higher + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + ) - # Period/Quarter and Clock (Top center) - period_clock_text = f"{game.get('period_text', '')} {game.get('clock', '')}".strip() - if game.get("is_halftime"): period_clock_text = "Halftime" # Override for halftime - - status_width = draw_overlay.textlength(period_clock_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 1 # Position at top - self._draw_text_with_outline(draw_overlay, period_clock_text, (status_x, status_y), self.fonts['time']) + # Shots on Goal + shots_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + home_shots = str(game.get("home_shots", "0")) + away_shots = str(game.get("away_shots", "0")) + shots_text = f"{away_shots} SHOTS {home_shots}" + shots_width = draw_overlay.textlength(shots_text, font=shots_font) + shots_x = (self.display_width - shots_width) // 2 + shots_y = ( + self.display_height - 10 + ) # centered #from 14 # Position score higher + self._draw_text_with_outline( + draw_overlay, shots_text, (shots_x, shots_y), shots_font + ) # Draw odds if available - if 'odds' in game and game['odds']: - self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) + if "odds" in game and game["odds"]: + self._draw_dynamic_odds( + draw_overlay, game["odds"], self.display_width, self.display_height + ) # Draw records or rankings if enabled if self.show_records or self.show_ranking: @@ -264,16 +280,20 @@ class HockeyLive(Hockey): 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})") - + 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) + 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 - 4 - self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_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: @@ -284,24 +304,31 @@ class HockeyLive(Hockey): away_text = f"#{away_rank}" else: # Show nothing for unranked teams when rankings are prioritized - away_text = '' + 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 = '' + away_text = "" elif self.show_records: # Show record only when rankings are disabled - away_text = game.get('away_record', '') + away_text = game.get("away_record", "") else: - away_text = '' - + away_text = "" + if away_text: away_record_x = 3 - 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) + 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: @@ -312,34 +339,45 @@ class HockeyLive(Hockey): home_text = f"#{home_rank}" else: # Show nothing for unranked teams when rankings are prioritized - home_text = '' + 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 = '' + home_text = "" elif self.show_records: # Show record only when rankings are disabled - home_text = game.get('home_record', '') + home_text = game.get("home_record", "") else: - home_text = '' - + home_text = "" + if home_text: - home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font) + 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 - 3 - 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.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 the text overlay onto the main image main_img = Image.alpha_composite(main_img, overlay) - main_img = main_img.convert('RGB') # Convert for display + 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 + self.display_manager.update_display() # Update display here for live except Exception as e: - self.logger.error(f"Error displaying live Hockey game: {e}", exc_info=True) # Changed log prefix + self.logger.error( + f"Error displaying live Hockey game: {e}", exc_info=True + ) # Changed log prefix diff --git a/src/base_classes/sports.py b/src/base_classes/sports.py index 7aa29404..78ac5164 100644 --- a/src/base_classes/sports.py +++ b/src/base_classes/sports.py @@ -1,26 +1,30 @@ -from typing import Dict, Any, Optional, List -from src.display_manager import DisplayManager -from src.cache_manager import CacheManager -from datetime import datetime, timedelta, timezone import logging import os -from src.odds_manager import OddsManager +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional +from typing import Callable +import pytz import requests +from PIL import Image, ImageDraw, ImageFont from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry -from PIL import Image, ImageDraw, ImageFont -import pytz -import time +from abc import ABC, abstractmethod + from src.background_data_service import get_background_service -from src.logo_downloader import download_missing_logo, LogoDownloader -from pathlib import Path # Import new architecture components (individual classes will import what they need) from src.base_classes.api_extractors import APIDataExtractor from src.base_classes.data_sources import DataSource +from src.cache_manager import CacheManager +from src.display_manager import DisplayManager from src.dynamic_team_resolver import DynamicTeamResolver +from src.logo_downloader import LogoDownloader, download_missing_logo +from src.odds_manager import OddsManager -class SportsCore: + +class SportsCore(ABC): def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str): self.logger = logger self.config = config @@ -41,20 +45,21 @@ class SportsCore: self.api_extractor: APIDataExtractor self.data_source: DataSource self.mode_config = config.get(f"{sport_key}_scoreboard", {}) # Changed config key - self.is_enabled = self.mode_config.get("enabled", False) - self.show_odds = self.mode_config.get("show_odds", False) - self.test_mode = self.mode_config.get("test_mode", False) + self.is_enabled: bool = self.mode_config.get("enabled", False) + self.show_odds: bool = self.mode_config.get("show_odds", False) + self.test_mode: bool = self.mode_config.get("test_mode", False) self.logo_dir = Path(self.mode_config.get("logo_dir", "assets/sports/ncaa_logos")) # Changed logo dir - self.update_interval = self.mode_config.get( + self.update_interval: int = self.mode_config.get( "update_interval_seconds", 60) - self.show_records = self.mode_config.get('show_records', False) - self.show_ranking = self.mode_config.get('show_ranking', False) + 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 = self.mode_config.get( + self.recent_games_to_show: int = self.mode_config.get( "recent_games_to_show", 5) # Show last 5 games - self.upcoming_games_to_show = self.mode_config.get( + self.upcoming_games_to_show: int = self.mode_config.get( "upcoming_games_to_show", 10) # Show next 10 games - self.show_favorite_teams_only = self.mode_config.get("show_favorite_teams_only", False) + self.show_favorite_teams_only: bool = self.mode_config.get("show_favorite_teams_only", False) + self.show_all_live: bool = self.mode_config.get("show_all_live", False) self.session = requests.Session() retry_strategy = Retry( @@ -114,6 +119,9 @@ class SportsCore: self.background_enabled = False self.logger.info("Background service disabled") + 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 @@ -314,14 +322,6 @@ class SportsCore: if not self.show_odds: return - # Check if we should only fetch for favorite teams - if self.show_favorite_teams_only: - home_abbr = game.get('home_abbr') - away_abbr = game.get('away_abbr') - if not (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams): - self.logger.debug(f"Skipping odds fetch for non-favorite game in favorites-only mode: {away_abbr}@{home_abbr}") - 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 \ @@ -485,16 +485,12 @@ class SportsCore: logging.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 - - # def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - # pass - - # def display(self, force_clear=False): - # pass + @abstractmethod def _fetch_data(self) -> Optional[Dict]: pass @@ -610,62 +606,22 @@ class SportsUpcoming(SportsCore): # 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") - - # Debug: Check what statuses we're seeing - status_counts = {} - status_names = {} # Track actual status names from ESPN - favorite_team_games = [] - for event in events: - game = self._extract_game_details(event) - if game: - status = "upcoming" if game['is_upcoming'] else "final" if game['is_final'] else "live" if game['is_live'] else "other" - status_counts[status] = status_counts.get(status, 0) + 1 - - # Track actual ESPN status names - actual_status = event.get('competitions', [{}])[0].get('status', {}).get('type', {}) - status_name = actual_status.get('name', 'Unknown') - status_state = actual_status.get('state', 'Unknown') - status_names[f"{status_name} ({status_state})"] = status_names.get(f"{status_name} ({status_state})", 0) + 1 - - # Check for favorite team games regardless of status - if (game['home_abbr'] in self.favorite_teams or game['away_abbr'] in self.favorite_teams): - favorite_team_games.append({ - 'teams': f"{game['away_abbr']} @ {game['home_abbr']}", - 'status': status, - 'date': game.get('start_time_utc', 'Unknown'), - 'espn_status': f"{status_name} ({status_state})" - }) - - # Special check for Tennessee game (Georgia @ Tennessee) - if (game['home_abbr'] == 'TENN' and game['away_abbr'] == 'UGA') or (game['home_abbr'] == 'UGA' and game['away_abbr'] == 'TENN'): - self.logger.info(f"Found Tennessee game: {game['away_abbr']} @ {game['home_abbr']} - {status} - {game.get('start_time_utc')} - ESPN: {status_name} ({status_state})") - - self.logger.info(f"Status breakdown: {status_counts}") - self.logger.info(f"ESPN status names: {status_names}") - if favorite_team_games: - self.logger.info(f"Favorite team games found: {len(favorite_team_games)}") - for game in favorite_team_games[:3]: # Show first 3 - self.logger.info(f" {game['teams']} - {game['status']} - {game['date']} - ESPN: {game['espn_status']}") + + 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") # Filter for favorite teams only if the config is set - if self.show_favorite_teams_only: - # Get all games involving favorite teams - favorite_team_games = [game for game in processed_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - + if self.show_favorite_teams_only: # Select one game per favorite team (earliest upcoming game for each team) team_games = [] for team in self.favorite_teams: - # Find games where this team is playing - team_specific_games = [game for game in favorite_team_games - if game['home_abbr'] == team or game['away_abbr'] == team] - - if team_specific_games: + # Find games where this team is playing + if team_specific_games := [game for game in processed_games if game['home_abbr'] == team or game['away_abbr'] == team]: # Sort by game time and take the earliest team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) team_games.append(team_specific_games[0]) @@ -1200,4 +1156,144 @@ class SportsRecent(SportsCore): # 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 \ No newline at end of file + self.logger.error(f"Error in display loop: {e}", exc_info=True) # Changed log prefix + +class SportsLive(SportsCore): + + def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, 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 + self.last_update = 0 + self.live_games = [] + self.current_game_index = 0 + self.last_game_switch = 0 + 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 + + @abstractmethod + def _test_mode_update(self) -> None: + return + + 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 = self.test_mode # test_mode is often from a base class or config + _no_data_interval_attr = self.no_data_interval # Default similar to NFLLiveManager + _update_interval_attr = self.update_interval # Default similar to NFLLiveManager + + interval = _no_data_interval_attr if not _live_games_attr and not _test_mode_attr else _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() + + if self.test_mode: + # Simulate clock running down in test mode + self._test_mode_update() + else: + # Fetch live game data + data = self._fetch_data() + new_live_games = [] + if data and "events" in data: + for game in data["events"]: + details = self._extract_game_details(game) + if details and (details["is_live"] or details["is_halftime"]): + # If show_favorite_teams_only is true, only add if it's a favorite. + # Otherwise, add all games. + if self.show_all_live or not self.show_favorite_teams_only or (self.show_favorite_teams_only and (details["home_abbr"] in self.favorite_teams or details["away_abbr"] in self.favorite_teams)): + if self.show_odds: + self._fetch_odds(details) + new_live_games.append(details) + # 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)) or # Check if game IDs changed + (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 + 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: + # 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 + + 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) + if not self.test_mode and len(self.live_games) > 1 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 \ No newline at end of file diff --git a/src/mlb_manager.py b/src/mlb_manager.py index 7537bc80..6b7bb313 100644 --- a/src/mlb_manager.py +++ b/src/mlb_manager.py @@ -1,22 +1,17 @@ -import time import logging -import requests -import json -from typing import Dict, Any, List, Optional -from datetime import datetime, timedelta, timezone import os -from PIL import Image, ImageDraw, ImageFont -import numpy as np -from .cache_manager import CacheManager -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, Optional + import pytz -from src.odds_manager import OddsManager -from src.background_data_service import get_background_service # Import baseball and standard sports classes from src.base_classes.baseball import Baseball, BaseballLive from src.base_classes.sports import SportsRecent, SportsUpcoming +from src.cache_manager import CacheManager +from src.display_manager import DisplayManager # Import the API counter function from web interface try: @@ -26,1407 +21,192 @@ except ImportError: def increment_api_counter(kind: str, count: int = 1): pass -# Get logger -logger = logging.getLogger(__name__) + +ESPN_MLB_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard" # Changed URL for NCAA FB + class BaseMLBManager(Baseball): """Base class for MLB managers using new baseball architecture.""" - def __init__(self, config: Dict[str, Any], display_manager, cache_manager: CacheManager): + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): # Initialize with sport_key for MLB - super().__init__(config, display_manager, cache_manager, logger, "mlb") - + self.logger = logging.getLogger("MLB") + super().__init__(config, display_manager, cache_manager, self.logger, "mlb") + # MLB-specific configuration - self.mlb_config = config.get('mlb_scoreboard', {}) - self.show_odds = self.mlb_config.get("show_odds", False) - self.favorite_teams = self.mlb_config.get('favorite_teams', []) - self.show_records = self.mlb_config.get('show_records', False) + self.show_odds = self.mode_config.get("show_odds", False) + self.favorite_teams = self.mode_config.get("favorite_teams", []) + self.show_records = self.mode_config.get("show_records", False) self.league = "mlb" - # Store reference to config instead of creating new ConfigManager - self.config_manager = None # Not used in this class - self.odds_manager = OddsManager(self.cache_manager, self.config_manager) - - # Logo handling - self.logo_dir = self.mlb_config.get('logo_dir', os.path.join('assets', 'sports', 'mlb_logos')) - if not os.path.exists(self.logo_dir): - self.logger.warning(f"MLB logos directory not found: {self.logo_dir}") - try: - os.makedirs(self.logo_dir, exist_ok=True) - self.logger.info(f"Created MLB logos directory: {self.logo_dir}") - except Exception as e: - self.logger.error(f"Failed to create MLB logos directory: {e}") - - # Set up session with retry logic - self.session = requests.Session() - retry_strategy = Retry( - total=3, - backoff_factor=1, - status_forcelist=[429, 500, 502, 503, 504] + + def _fetch_mlb_api_data(self, use_cache: bool = True) -> Optional[Dict]: + """ + Fetches the full season schedule for NCAAFB using week-by-week 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) + start_of_last_month = now.replace(day=1, month=now.month - 1) + last_day_of_next_month = now.replace(day=1, month=now.month + 2) - timedelta( + days=1 ) - adapter = HTTPAdapter(max_retries=retry_strategy) - self.session.mount("http://", adapter) - self.session.mount("https://", adapter) - self.headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' - } - - # Initialize background data service - background_config = self.mlb_config.get("background_service", {}) - if background_config.get("enabled", True): # Default to enabled - max_workers = background_config.get("max_workers", 3) - self.background_service = get_background_service(self.cache_manager, max_workers) - self.background_fetch_requests = {} # Track background fetch requests - self.background_enabled = True - self.logger.info(f"[MLB] Background service enabled with {max_workers} workers") - else: - self.background_service = None - self.background_fetch_requests = {} - self.background_enabled = False - self.logger.info("[MLB] Background service disabled") + start_of_last_month_str = start_of_last_month.strftime("%Y%m%d") + last_day_of_next_month_str = last_day_of_next_month.strftime("%Y%m%d") + datestring = f"{start_of_last_month_str}-{last_day_of_next_month_str}" + cache_key = f"mlb_schedule_{datestring}" - def _get_team_logo(self, team_abbr: str) -> Optional[Image.Image]: - """Get team logo from the configured directory.""" - try: - logo_path = os.path.join(self.logo_dir, f"{team_abbr}.png") - if os.path.exists(logo_path): - logo = Image.open(logo_path) - if logo.mode != 'RGBA': - logo = logo.convert('RGBA') - return logo - else: - logger.warning(f"Logo not found for team {team_abbr}") - return None - except Exception as e: - logger.error(f"Error loading logo for team {team_abbr}: {e}") - return None - - 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. - - Args: - draw: ImageDraw object - text: Text to draw - position: (x, y) position to draw the text - font: Font to use - fill: Text color (default: white) - outline_color: Outline color (default: black) - """ - x, y = position - - # Draw the outline by drawing the text in black at 8 positions around the text - 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 the text in the specified color - draw.text((x, y), text, font=font, fill=fill) - - def _draw_base_indicators(self, draw: ImageDraw.Draw, bases_occupied: List[bool], center_x: int, y: int) -> None: - """Draw base indicators on the display.""" - base_size = 8 # Increased from 6 to 8 for better visibility - base_spacing = 10 # Increased from 8 to 10 for better spacing - - # Draw diamond outline with thicker lines - diamond_points = [ - (center_x, y), # Home - (center_x - base_spacing, y - base_spacing), # First - (center_x, y - 2 * base_spacing), # Second - (center_x + base_spacing, y - base_spacing) # Third - ] - - # Draw thicker diamond outline - for i in range(len(diamond_points)): - start = diamond_points[i] - end = diamond_points[(i + 1) % len(diamond_points)] - draw.line([start, end], fill=(255, 255, 255), width=2) # Added width parameter for thicker lines - - # Draw occupied bases with larger circles and outline - for i, occupied in enumerate(bases_occupied): - x = diamond_points[i+1][0] - base_size//2 - y = diamond_points[i+1][1] - base_size//2 - - # Draw base circle with outline - if occupied: - # Draw white outline - draw.ellipse([x-1, y-1, x + base_size+1, y + base_size+1], fill=(255, 255, 255)) - # Draw filled circle - draw.ellipse([x+1, y+1, x + base_size-1, y + base_size-1], fill=(0, 0, 0)) - else: - # Draw empty base with outline - draw.ellipse([x, y, x + base_size, y + base_size], outline=(255, 255, 255), width=1) - - def _create_game_display(self, game_data: Dict[str, Any]) -> Image.Image: - """Create a display image for an MLB game with team logos, score, and game state.""" - width = self.display_manager.matrix.width - height = self.display_manager.matrix.height - image = Image.new('RGB', (width, height), color=(0, 0, 0)) - - # Make logos 150% of display dimensions to allow them to extend off screen - max_width = int(width * 1.5) - max_height = int(height * 1.5) - - # Load team logos - away_logo = self._get_team_logo(game_data['away_team']) - home_logo = self._get_team_logo(game_data['home_team']) - - if away_logo and home_logo: - # Resize maintaining aspect ratio - away_logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - home_logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - - # Create a single overlay for both logos - overlay = Image.new('RGBA', (width, height), (0, 0, 0, 0)) - - # Calculate vertical center line for alignment - center_y = height // 2 - - # Draw home team logo (far right, extending beyond screen) - home_x = width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - - # Paste the home logo onto the overlay - overlay.paste(home_logo, (home_x, home_y), home_logo) - - # Draw away team logo (far left, extending beyond screen) - away_x = -2 - away_y = center_y - (away_logo.height // 2) - - overlay.paste(away_logo, (away_x, away_y), away_logo) - - # Composite the overlay with the main image - image = image.convert('RGBA') - image = Image.alpha_composite(image, overlay) - image = image.convert('RGB') - - draw = ImageDraw.Draw(image) - - # For upcoming games, show date and time stacked in the center - if game_data['status'] == 'status_scheduled': - # Show "Next Game" at the top using NHL-style font - status_text = "Next Game" - # Set font size for BDF font - self.display_manager.calendar_font.set_char_size(height=7*64) # 7 pixels high, 64 units per pixel - status_width = self.display_manager.get_text_width(status_text, self.display_manager.calendar_font) - status_x = (width - status_width) // 2 - status_y = 2 - # Draw on the current image - self.display_manager.draw = draw - self.display_manager._draw_bdf_text(status_text, status_x, status_y, color=(255, 255, 255), font=self.display_manager.calendar_font) - - # Format game date and time - game_time = datetime.fromisoformat(game_data['start_time'].replace('Z', '+00:00')) - timezone_str = self.config.get('timezone', 'UTC') - try: - tz = pytz.timezone(timezone_str) - except pytz.exceptions.UnknownTimeZoneError: - logger.warning(f"Unknown timezone: {timezone_str}, falling back to UTC") - tz = pytz.UTC - if game_time.tzinfo is None: - game_time = game_time.replace(tzinfo=pytz.UTC) - local_time = game_time.astimezone(tz) - - # 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: - game_date = self.display_manager.format_date_with_ordinal(local_time) - - game_time_str = self._format_game_time(game_data['start_time']) - - # Draw date and time using NHL-style fonts - date_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - time_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - - # Draw date in center - date_width = draw.textlength(game_date, font=date_font) - date_x = (width - date_width) // 2 - date_y = (height - date_font.size) // 2 - 3 - # draw.text((date_x, date_y), game_date, font=date_font, fill=(255, 255, 255)) - self._draw_text_with_outline(draw, game_date, (date_x, date_y), date_font) - - # Draw time below date - time_width = draw.textlength(game_time_str, font=time_font) - time_x = (width - time_width) // 2 - time_y = date_y + 10 - # draw.text((time_x, time_y), game_time_str, font=time_font, fill=(255, 255, 255)) - self._draw_text_with_outline(draw, game_time_str, (time_x, time_y), time_font) - - # Draw odds if available - if 'odds' in game_data and game_data['odds']: - self._draw_dynamic_odds(draw, game_data['odds'], width, height) - - # For recent/final games, show scores and status - elif game_data['status'] in ['status_final', 'final', 'completed']: - # Show "Final" at the top using NHL-style font - status_text = "Final" - # Set font size for BDF font - self.display_manager.calendar_font.set_char_size(height=7*64) # 7 pixels high, 64 units per pixel - status_width = self.display_manager.get_text_width(status_text, self.display_manager.calendar_font) - status_x = (width - status_width) // 2 - status_y = 2 - # Draw on the current image - self.display_manager.draw = draw - self.display_manager._draw_bdf_text(status_text, status_x, status_y, color=(255, 255, 255), font=self.display_manager.calendar_font) - - # Always show scores for recent/final games - away_score = str(game_data['away_score']) - home_score = str(game_data['home_score']) - display_text = f"{away_score}-{home_score}" - font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) - display_width = draw.textlength(display_text, font=font) - display_x = (width - display_width) // 2 - display_y = (height - font.size) // 2 - display_y += 4 - self._draw_text_with_outline(draw, display_text, (display_x, display_y), font, fill=(255, 255, 255)) - - # Show spreads and over/under if available (on top of scores) - if 'odds' in game_data and game_data['odds']: - self._draw_dynamic_odds(draw, game_data['odds'], width, height) - - # For live games, show detailed game state - elif game_data['status'] == 'status_in_progress' or game_data.get('live', False): - # Show "Live" at the top using NHL-style font - status_text = "Live" - # Set font size for BDF font - self.display_manager.calendar_font.set_char_size(height=7*64) # 7 pixels high, 64 units per pixel - status_width = self.display_manager.get_text_width(status_text, self.display_manager.calendar_font) - status_x = (width - status_width) // 2 - status_y = 2 - # Draw on the current image - self.display_manager.draw = draw - self.display_manager._draw_bdf_text(status_text, status_x, status_y, color=(255, 255, 255), font=self.display_manager.calendar_font) - - # Draw scores at the bottom using NHL-style font - away_score = str(game_data['away_score']) - home_score = str(game_data['home_score']) - score_text = f"{away_score}-{home_score}" - score_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) - - # Calculate position for the score text - score_width = draw.textlength(score_text, font=score_font) - score_x = (width - score_width) // 2 - score_y = height - score_font.size - 2 - # draw.text((score_x, score_y), score_text, font=score_font, fill=(255, 255, 255)) - self._draw_text_with_outline(draw, score_text, (score_x, score_y), score_font) - - # For live games, we are not adding the debug spread display yet - # It can be added later if needed by copying the logic from recent/upcoming - pass - - # Draw records for upcoming and recent games - if self.show_records and game_data['status'] in ['status_scheduled', 'status_final', 'final', 'completed']: - try: - record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - except IOError: - record_font = ImageFont.load_default() - - away_record = game_data.get('away_record', '') - home_record = game_data.get('home_record', '') - - # Using textbbox is more accurate for height than .size - record_bbox = draw.textbbox((0,0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = height - record_height - - if away_record: - away_record_x = 0 - self._draw_text_with_outline(draw, away_record, (away_record_x, record_y), record_font) - - if home_record: - home_record_bbox = draw.textbbox((0,0), home_record, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = width - home_record_width - self._draw_text_with_outline(draw, home_record, (home_record_x, record_y), record_font) - - return image - - def _format_game_time(self, game_time: str) -> str: - """Format game time for display.""" - try: - # Get timezone from config - timezone_str = self.config.get('timezone', 'UTC') - try: - tz = pytz.timezone(timezone_str) - except pytz.exceptions.UnknownTimeZoneError: - logger.warning(f"Unknown timezone: {timezone_str}, falling back to UTC") - tz = pytz.UTC - - # Convert game time to local timezone - dt = datetime.fromisoformat(game_time.replace('Z', '+00:00')) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=pytz.UTC) - local_dt = dt.astimezone(tz) - - return local_dt.strftime("%I:%M%p").lstrip('0') - except Exception as e: - logger.error(f"Error formatting game time: {e}") - return "TBD" - - def _fetch_mlb_api_data(self, use_cache: bool = True) -> Dict[str, Any]: - """ - Fetch MLB game data from the ESPN API. - Updated to use background service cache for Recent/Upcoming managers. - """ - # Define cache key based on dates - now = datetime.now(timezone.utc) - yesterday = now - timedelta(days=1) - tomorrow = now + timedelta(days=1) - dates_str = f"{yesterday.strftime('%Y%m%d')}-{now.strftime('%Y%m%d')}-{tomorrow.strftime('%Y%m%d')}" - cache_key = f"mlb_api_data_{dates_str}" - - # If using cache, try to load from cache first if use_cache: - # For Recent/Upcoming managers, try background service cache first - if hasattr(self, '__class__') and any(x in self.__class__.__name__ for x in ['Recent', 'Upcoming']): - if self.cache_manager.is_background_data_available(cache_key, 'mlb'): - cached_data = self.cache_manager.get_background_cached_data(cache_key, 'mlb') - if cached_data: - self.logger.info(f"[MLB] Using background service cache for {cache_key}") - return cached_data - self.logger.info(f"[MLB] Background data not available, fetching directly for {cache_key}") - - # Fallback to regular cache strategy - cached_data = self.cache_manager.get_with_auto_strategy(cache_key) + cached_data = self.cache_manager.get(cache_key) if cached_data: - self.logger.info("Using cached MLB API data.") - return cached_data + # Validate cached data structure + if isinstance(cached_data, dict) and "events" in cached_data: + self.logger.info(f"Using cached schedule for {datestring}") + return cached_data + elif isinstance(cached_data, list): + # Handle old cache format (list of events) + self.logger.info( + f"Using cached schedule for {datestring} (legacy format)" + ) + return {"events": cached_data} + else: + self.logger.warning( + f"Invalid cached data format for {datestring}: {type(cached_data)}" + ) + # Clear invalid cache + self.cache_manager.clear_cache(cache_key) - try: - # Check if test mode is enabled - if self.mlb_config.get('test_mode', False): - self.logger.info("Using test mode data for MLB") - return { - 'test_game_1': { - 'id': 'test_game_1', - 'away_team': 'TB', - 'home_team': 'TEX', - 'away_score': 3, - 'home_score': 2, - 'status': 'in', - 'status_state': 'in', - 'inning': 7, - 'inning_half': 'bottom', - 'balls': 2, - 'strikes': 1, - 'outs': 1, - 'bases_occupied': [True, False, True], # Runner on 1st and 3rd - 'start_time': datetime.now(timezone.utc).isoformat() - } - } - - # Get dates for API request - now = datetime.now(timezone.utc) - yesterday = now - timedelta(days=1) - tomorrow = now + timedelta(days=1) - - # Format dates for API - dates = [ - yesterday.strftime("%Y%m%d"), - now.strftime("%Y%m%d"), - tomorrow.strftime("%Y%m%d") - ] - - all_games = {} - - # Fetch games for each date - for date in dates: - # ESPN API endpoint for MLB games with date parameter - url = f"https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard?dates={date}" - - self.logger.info(f"Fetching MLB games from ESPN API for date: {date}") - response = self.session.get(url, headers=self.headers, timeout=10) - response.raise_for_status() - - data = response.json() - - # Increment API counter for sports data call - increment_api_counter('sports', 1) - - self.logger.info(f"Found {len(data.get('events', []))} total games for date {date}") - - for event in data.get('events', []): - game_id = event['id'] - status = event['status']['type']['name'].lower() - status_state = event['status']['type']['state'].lower() - - # Get team information - competitors = event['competitions'][0]['competitors'] - home_team = next(c for c in competitors if c['homeAway'] == 'home') - away_team = next(c for c in competitors if c['homeAway'] == 'away') - - # Get team abbreviations - home_abbr = home_team['team']['abbreviation'] - away_abbr = away_team['team']['abbreviation'] - - # Get team records - Corrected path - 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 background service is disabled, fall back to synchronous fetch + if not self.background_enabled or not self.background_service: + pass + # return self._fetch_ncaa_api_data_sync(use_cache) - # 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: {status}, State: {status_state})") - - # Only log detailed information for favorite teams - if is_favorite_game: - self.logger.info(f"Found favorite team game: {away_abbr} @ {home_abbr} (Status: {status}, State: {status_state})") - self.logger.debug(f"[MLB] Full status data: {event['status']}") - self.logger.debug(f"[MLB] Status type: {status}, State: {status_state}") - self.logger.debug(f"[MLB] Status detail: {event['status']['type'].get('detail', '')}") - self.logger.debug(f"[MLB] Status shortDetail: {event['status']['type'].get('shortDetail', '')}") - - # Get game state information - if status_state == 'in': - # For live games, get detailed state - inning = event['status'].get('period', 1) # Get inning from status period - - # Get inning information from status - status_detail = event['status']['type'].get('detail', '').lower() - status_short = event['status']['type'].get('shortDetail', '').lower() - - if is_favorite_game: - self.logger.debug(f"[MLB] Raw status detail: {event['status']['type'].get('detail')}") - self.logger.debug(f"[MLB] Raw status short: {event['status']['type'].get('shortDetail')}") - - # Determine inning half from status information - inning_half = 'top' # Default + self.logger.info(f"Fetching full {datestring} season schedule from ESPN API...") - # Handle end of inning: next inning is top - if 'end' in status_detail or 'end' in status_short: - inning_half = 'top' - inning = event['status'].get('period', 1) + 1 # Use period and increment for next inning - if is_favorite_game: - self.logger.debug(f"[MLB] 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"[MLB] 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"[MLB] 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"[MLB] Detected top of inning: {inning}") - - if is_favorite_game: - self.logger.debug(f"[MLB] Status detail: {status_detail}") - self.logger.debug(f"[MLB] Status short: {status_short}") - self.logger.debug(f"[MLB] Determined inning: {inning_half} {inning}") - - # Get count and bases from situation - situation = event['competitions'][0].get('situation', {}) - - if is_favorite_game: - self.logger.debug(f"[MLB] 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"[MLB] Full situation data: {situation}") - self.logger.debug(f"[MLB] Count object: {count}") - self.logger.debug(f"[MLB] Raw count values - balls: {balls}, strikes: {strikes}") - self.logger.debug(f"[MLB] 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"[MLB] Using summary count: {count_summary}") - except (ValueError, AttributeError): - if is_favorite_game: - self.logger.debug("[MLB] 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"[MLB] Using direct situation count: balls={balls}, strikes={strikes}") - self.logger.debug(f"[MLB] Full situation keys: {list(situation.keys())}") - - if is_favorite_game: - self.logger.debug(f"[MLB] 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"[MLB] 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] - - all_games[game_id] = { - 'id': game_id, - 'away_team': away_abbr, - 'home_team': home_abbr, - 'away_score': away_team['score'], - 'home_score': home_team['score'], - 'away_record': away_record, - 'home_record': home_record, - 'status': status, - 'status_state': status_state, - 'inning': inning, - 'inning_half': inning_half, - 'balls': balls, - 'strikes': strikes, - 'outs': outs, - 'bases_occupied': bases_occupied, - 'start_time': event['date'] - } - - # Only log favorite team games - favorite_games = [game for game in all_games.values() - if game['home_team'] in self.favorite_teams or - game['away_team'] in self.favorite_teams] - if favorite_games: - self.logger.info(f"Found {len(favorite_games)} games for favorite teams: {self.favorite_teams}") - for game in favorite_games: - self.logger.info(f"Favorite team game: {game['away_team']} @ {game['home_team']} (Status: {game['status']}, State: {game['status_state']})") - - # Save to cache if caching is enabled - if use_cache: - self.cache_manager.set(cache_key, all_games) - - return all_games - - except Exception as e: - self.logger.error(f"Error fetching MLB data from ESPN API: {e}") - return {} + # Start background fetch + self.logger.info( + f"Starting background fetch for {datestring} season schedule..." + ) - 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.""" - 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') + def fetch_callback(result): + """Callback when background fetch completes.""" + if result.success: + self.logger.info( + f"Background fetch completed for {datestring}: {len(result.data.get('events'))} events" + ) + else: + self.logger.error( + f"Background fetch failed for {datestring}: {result.error}" + ) - # 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 + # Clean up request tracking + if datestring in self.background_fetch_requests: + del self.background_fetch_requests[datestring] - # Determine which team is favored (has negative spread) - home_favored = home_spread is not None and home_spread < 0 - away_favored = away_spread is not None and 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}") + # 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=now.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[datestring] = request_id + + # For immediate response, try to get partial data + partial_data = self._get_weeks_data() + if partial_data: + return partial_data + return None + + def _fetch_data(self) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, MLBLiveManager): + return self._fetch_todays_games() 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.display_manager.extra_small_font - - 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: - ou_text = f"O/U: {over_under}" - font = self.display_manager.extra_small_font - ou_width = draw.textlength(ou_text, font=font) - - if favored_side == 'home': - # Home team is 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 team is 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)) + return self._fetch_mlb_api_data(use_cache=True) + class MLBLiveManager(BaseMLBManager, BaseballLive): """Manager for displaying live MLB games.""" - def __init__(self, config: Dict[str, Any], display_manager, cache_manager: CacheManager): + + def __init__( + self, config: Dict[str, Any], display_manager, cache_manager: CacheManager + ): super().__init__(config, display_manager, cache_manager) self.logger.info("Initialized MLB Live Manager") - self.live_games = [] - self.current_game = None # Initialize current_game to None - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mlb_config.get('live_update_interval', 20) - self.no_data_interval = 300 # 5 minutes when no live games - self.last_game_switch = 0 # Track when we last switched games - self.game_display_duration = self.mlb_config.get('live_game_duration', 30) # Display each live game for 30 seconds - self.last_display_update = 0 # Track when we last updated the display - self.last_log_time = 0 - self.log_interval = 300 # Only log status every 5 minutes - 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 - self.test_mode = self.mlb_config.get('test_mode', False) # Initialize with test game only if test mode is enabled if self.test_mode: self.current_game = { - "home_team": "TB", - "away_team": "TEX", + "home_abbr": "TB", + "home_id": "234", + "away_abbr": "TEX", + "away_id": "234", "home_score": "3", "away_score": "2", - "status": "live", - "status_state": "live", "inning": 5, "inning_half": "top", "balls": 2, "strikes": 1, "outs": 1, "bases_occupied": [True, False, True], - "home_logo_path": os.path.join(self.logo_dir, "TB.png"), - "away_logo_path": os.path.join(self.logo_dir, "TEX.png"), + "home_logo_path": Path(self.logo_dir, "TB.png"), + "away_logo_path": Path(self.logo_dir, "TEX.png"), "start_time": datetime.now(timezone.utc).isoformat(), + "is_live": True, "is_final": False, "is_upcoming": False, } self.live_games = [self.current_game] self.logger.info("Initialized MLBLiveManager with test game: TB vs TEX") else: self.logger.info("Initialized MLBLiveManager in live mode") - def update(self): - """Update live game data.""" - current_time = time.time() - # Use longer interval if no game data - interval = self.no_data_interval if not self.live_games else self.update_interval - - if current_time - self.last_update >= interval: - self.last_update = current_time - - if self.test_mode: - # For testing, we'll just update the game state to show it's working - if self.current_game: - # Update inning half - 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 - - # Update count - self.current_game["balls"] = (self.current_game["balls"] + 1) % 4 - self.current_game["strikes"] = (self.current_game["strikes"] + 1) % 3 - - # Update outs - self.current_game["outs"] = (self.current_game["outs"] + 1) % 3 - - # Update bases - self.current_game["bases_occupied"] = [ - not self.current_game["bases_occupied"][0], - not self.current_game["bases_occupied"][1], - not self.current_game["bases_occupied"][2] - ] - - # Update score occasionally - 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) - else: - # Fetch live game data from MLB API, bypassing the cache - games = self._fetch_mlb_api_data(use_cache=False) - if games: - - # --- Optimization: Filter for favorite teams before processing --- - if self.mlb_config.get("show_favorite_teams_only", False): - games = { - game_id: game for game_id, game in games.items() - if game['home_team'] in self.favorite_teams or game['away_team'] in self.favorite_teams - } - self.logger.info(f"[MLB Live] Filtered to {len(games)} games for favorite teams.") - - # Find all live games - new_live_games = [] - for game in games.values(): - # Only process games that are actually in progress - if game['status_state'] == 'in' and game['status'] == 'status_in_progress': - # Check if this is a favorite team game - is_favorite_game = (game['home_team'] in self.favorite_teams or - game['away_team'] in self.favorite_teams) - - # Only filter by favorite teams if show_favorite_teams_only is True - if self.mlb_config.get("show_favorite_teams_only", False) and not is_favorite_game: - self.logger.debug(f"[MLB Live] Skipping game {game['away_team']} @ {game['home_team']} - not a favorite team.") - continue - - self._fetch_odds(game) - # Ensure scores are valid numbers - try: - game['home_score'] = int(game['home_score']) - game['away_score'] = int(game['away_score']) - new_live_games.append(game) - except (ValueError, TypeError): - self.logger.warning(f"Invalid score format for game {game['away_team']} @ {game['home_team']}") - - # Only log if there's a change in games or enough time has passed - should_log = ( - current_time - self.last_log_time >= self.log_interval or - len(new_live_games) != len(self.live_games) or - not self.live_games # Log if we had no games before - ) - - if should_log: - if new_live_games: - logger.info(f"[MLB] Found {len(new_live_games)} live games") - for game in new_live_games: - logger.info(f"[MLB] Live game: {game['away_team']} vs {game['home_team']} - {game['inning_half']}{game['inning']}, {game['balls']}-{game['strikes']}") - else: - logger.info("[MLB] No live games found") - self.last_log_time = current_time - - if new_live_games: - # Update the current game with the latest data - for new_game in new_live_games: - if self.current_game and ( - (new_game['home_team'] == self.current_game['home_team'] and - new_game['away_team'] == self.current_game['away_team']) or - (new_game['home_team'] == self.current_game['away_team'] and - new_game['away_team'] == self.current_game['home_team']) - ): - self.current_game = new_game - break - - # Only update the games list if we have new games - if not self.live_games or set(game['away_team'] + game['home_team'] for game in new_live_games) != set(game['away_team'] + game['home_team'] for game in self.live_games): - self.live_games = new_live_games - # If we don't have a current game or it's not in the new list, start from the beginning - if not self.current_game or self.current_game not in self.live_games: - self.current_game_index = 0 - self.current_game = self.live_games[0] - self.last_game_switch = current_time - - # Always update display when we have new data, but limit to once per second - if current_time - self.last_display_update >= 1.0: - # self.display(force_clear=True) # REMOVED: DisplayController handles this - self.last_display_update = current_time - else: - # No live games found - self.live_games = [] - self.current_game = None - - # Check if it's time to switch games - if len(self.live_games) > 1 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 - - # Log team switching - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - self.logger.info(f"[MLB Live] Showing {away_abbr} vs {home_abbr}") - - # Force display update when switching games - # self.display(force_clear=True) # REMOVED: DisplayController handles this - self.last_display_update = current_time - - def _create_live_game_display(self, game_data: Dict[str, Any]) -> Image.Image: - """Create a display image for a live MLB game.""" - width = self.display_manager.matrix.width - height = self.display_manager.matrix.height - image = Image.new('RGB', (width, height), color=(0, 0, 0)) - - # Make logos 150% of display dimensions to allow them to extend off screen - max_width = int(width * 1.5) - max_height = int(height * 1.5) - - # Load and place team logos - away_logo = self._get_team_logo(game_data['away_team']) - home_logo = self._get_team_logo(game_data['home_team']) - - if away_logo and home_logo: - # Resize maintaining aspect ratio - away_logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - home_logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - - # Create a single overlay for both logos - overlay = Image.new('RGBA', (width, height), (0, 0, 0, 0)) - - # Calculate vertical center line for alignment - center_y = height // 2 - - # Draw home team logo (far right, extending beyond screen) - home_x = width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - - # Paste the home logo onto the overlay - overlay.paste(home_logo, (home_x, home_y), home_logo) - - # Draw away team logo (far left, extending beyond screen) - away_x = -2 - away_y = center_y - (away_logo.height // 2) - - overlay.paste(away_logo, (away_x, away_y), away_logo) - - # Composite the overlay with the main image - image = image.convert('RGBA') - image = Image.alpha_composite(image, overlay) - image = image.convert('RGB') - - draw = ImageDraw.Draw(image) - - # --- Live Game Specific Elements --- - - # Define default text color - text_color = (255, 255, 255) - - # Draw Inning (Top Center) - inning_half = game_data['inning_half'] - inning_num = game_data['inning'] - if game_data['status'] in ['status_final', 'final', 'completed']: - inning_text = "FINAL" - else: - inning_half_indicator = "▲" if game_data['inning_half'].lower() == 'top' else "▼" - inning_num = game_data['inning'] - inning_text = f"{inning_half_indicator}{inning_num}" - - inning_bbox = draw.textbbox((0, 0), inning_text, font=self.display_manager.font) - inning_width = inning_bbox[2] - inning_bbox[0] - inning_x = (width - inning_width) // 2 - inning_y = 1 # Position near top center - # draw.text((inning_x, inning_y), inning_text, fill=(255, 255, 255), font=self.display_manager.font) - self._draw_text_with_outline(draw, inning_text, (inning_x, inning_y), self.display_manager.font) - - # --- REVISED BASES AND OUTS DRAWING --- - bases_occupied = game_data['bases_occupied'] # [1st, 2nd, 3rd] - outs = game_data.get('outs', 0) - inning_half = game_data['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 (moved up 3 pixels) - - # Center the BASE cluster horizontally - bases_origin_x = (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.polygon(poly2, fill=base_color_occupied) - else: draw.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.polygon(poly3, fill=base_color_occupied) - else: draw.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.polygon(poly1, fill=base_color_occupied) - else: draw.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.ellipse(coords, fill=circle_color_out) - else: - draw.ellipse(coords, outline=circle_color_empty_outline) - - # --- Draw Balls-Strikes Count (BDF Font) --- - balls = game_data.get('balls', 0) - strikes = game_data.get('strikes', 0) - - # Add debug logging for count with cooldown - current_time = time.time() - if (game_data['home_team'] in self.favorite_teams or game_data['away_team'] in self.favorite_teams) and \ - current_time - self.last_count_log_time >= self.count_log_interval: - self.logger.debug(f"[MLB] Displaying count: {balls}-{strikes}") - self.logger.debug(f"[MLB] Raw count data: balls={game_data.get('balls')}, strikes={game_data.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 - # self.display_manager._draw_bdf_text(count_text, count_x, count_y, text_color, font=bdf_font) - # Use _draw_text_with_outline for count text - # self._draw_text_with_outline(draw, count_text, (count_x, count_y), bdf_font, fill=text_color) - - # Draw Balls-Strikes Count with outline using BDF font - # Define outline color (consistent with _draw_text_with_outline default) - 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) # Use a specific name for score text color - - # Helper function for outlined text - def draw_bottom_outlined_text(x, y, text): - self._draw_text_with_outline(draw, text, (x, y), score_font, fill=score_text_color, outline_color=outline_color) - - away_abbr = game_data['away_team'] - home_abbr = game_data['home_team'] - away_score_str = str(game_data['away_score']) - home_score_str = str(game_data['home_score']) - - away_text = f"{away_abbr}:{away_score_str}" - home_text = f"{home_abbr}:{home_score_str}" - - # Calculate Y position (bottom edge) - # Get font height (approximate or precise) - try: - font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1] - except AttributeError: - font_height = 8 # Fallback for default font - score_y = 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.textbbox((0, 0), home_text, font=score_font) - home_text_width = home_text_bbox[2] - home_text_bbox[0] - home_score_x = 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_data and game_data['odds']: - self._draw_dynamic_odds(draw, game_data['odds'], width, height) - - return image - - def display(self, force_clear: bool = False): - """Display live game information.""" - if not self.current_game: - return - - try: - # Create and display the game image using the new method - game_image = self._create_live_game_display(self.current_game) - # Set the image in the display manager - self.display_manager.image = game_image - self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) - # Update the display - self.display_manager.update_display() - except Exception as e: - logger.error(f"[MLB] Error displaying live game: {e}", exc_info=True) class MLBRecentManager(BaseMLBManager, SportsRecent): """Manager for displaying recent MLB games.""" - def __init__(self, config: Dict[str, Any], display_manager, cache_manager: CacheManager): + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): super().__init__(config, display_manager, cache_manager) - self.logger.info("Initialized MLB Recent Manager") - self.recent_games = [] - self.current_game = None - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mlb_config.get('recent_update_interval', 3600) - self.recent_games_to_show = self.mlb_config.get('recent_games_to_show', 5) # Show last 5 games - self.last_game_switch = 0 # Track when we last switched games - self.game_display_duration = 10 # Display each game for 10 seconds - self.last_warning_time = 0 - self.warning_cooldown = 300 # Only show warning every 5 minutes - logger.info(f"Initialized MLBRecentManager with {len(self.favorite_teams)} favorite teams") - self.last_log_time = 0 - self.log_interval = 300 # 5 minutes + self.logger = logging.getLogger("MLBRecentManager") # Changed logger name + self.logger.info( + f"Initialized MLBRecentManager with {len(self.favorite_teams)} favorite teams" + ) # Changed log prefix - def update(self): - """Update recent games data.""" - current_time = time.time() - if self.last_update != 0 and (current_time - self.last_update < self.update_interval): - return - - try: - # Fetch data from MLB API - games = self._fetch_mlb_api_data() - if not games: - logger.warning("[MLB] No games returned from API for recent games update.") - return - - # --- Optimization: Filter for favorite teams before processing --- - if self.mlb_config.get("show_favorite_teams_only", False): - games = { - game_id: game for game_id, game in games.items() - if game['home_team'] in self.favorite_teams or game['away_team'] in self.favorite_teams - } - self.logger.info(f"[MLB Recent] Filtered to {len(games)} games for favorite teams.") - - # Process games - new_recent_games = [] - - self.logger.info(f"[MLB] Processing {len(games)} games for recent games...") - - # Log all games found for debugging - all_games_log = [] - favorite_games_log = [] - - for game_id, game in games.items(): - self.logger.debug(f"[MLB] Processing game {game_id} for recent games...") - # Convert game time to UTC datetime - game_time_str = game['start_time'].replace('Z', '+00:00') - game_time = datetime.fromisoformat(game_time_str) - if game_time.tzinfo is None: - game_time = game_time.replace(tzinfo=timezone.utc) - - # Check if this is a favorite team game - is_favorite_game = (game['home_team'] in self.favorite_teams or - game['away_team'] in self.favorite_teams) - - # Log all games for debugging - game_info = f"{game['away_team']} @ {game['home_team']} (Status: {game['status']}, State: {game['status_state']})" - all_games_log.append(game_info) - - if is_favorite_game: - favorite_games_log.append(game_info) - self.logger.info(f"[MLB] Favorite team game found: {game['away_team']} @ {game['home_team']}") - self.logger.info(f"[MLB] Game time (UTC): {game_time}") - self.logger.info(f"[MLB] Game status: {game['status']}, State: {game['status_state']}") - - # Only filter by favorite teams if show_favorite_teams_only is True - if self.mlb_config.get("show_favorite_teams_only", False) and not is_favorite_game: - self.logger.debug(f"[MLB] Skipping game {game_id} - not a favorite team.") - continue - - # Use status_state to determine if game is final - is_final = game['status_state'] in ['post', 'final', 'completed'] - - self.logger.info(f"[MLB] Game Time: {game_time.isoformat()}") - self.logger.info(f"[MLB] Is final: {is_final}") - - # Only add favorite team games that are final - if is_final: - self.logger.info(f"[MLB] Adding game {game_id} to recent games list.") - self._fetch_odds(game) - new_recent_games.append(game) - else: - self.logger.info(f"[MLB] Skipping game {game_id} - not final.") - - # Log summary of all games found - self.logger.info(f"[MLB] All games found ({len(all_games_log)}): {all_games_log}") - self.logger.info(f"[MLB] Favorite team games found ({len(favorite_games_log)}): {favorite_games_log}") - - # Sort by game time (most recent first) and apply per-team logic - new_recent_games.sort(key=lambda x: x['start_time'], reverse=True) - - # If showing favorite teams only, select one game per team - if self.mlb_config.get("show_favorite_teams_only", False): - # Select one game per favorite team (most recent game for each team) - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - team_specific_games = [game for game in new_recent_games - if game['home_team'] == team or game['away_team'] == team] - - if team_specific_games: - # Take the most recent (first in sorted list) - team_games.append(team_specific_games[0]) - - # Sort the final list by game time (most recent first) - team_games.sort(key=lambda x: x['start_time'], reverse=True) - new_recent_games = team_games - else: - # Limit to configured number if not using favorite teams only - new_recent_games = new_recent_games[:self.recent_games_to_show] - - if new_recent_games: - logger.info(f"[MLB] Found {len(new_recent_games)} recent games for favorite teams: {self.favorite_teams}") - self.recent_games = new_recent_games - if not self.current_game: - self.current_game = self.recent_games[0] - else: - logger.info("[MLB] No recent games found for favorite teams") - self.recent_games = [] - self.current_game = None - - self.last_update = current_time - - except Exception as e: - logger.error(f"[MLB] Error updating recent games: {e}", exc_info=True) - - def display(self, force_clear: bool = False): - """Display recent games.""" - if not self.recent_games: - current_time = time.time() - if current_time - self.last_warning_time > self.warning_cooldown: - logger.info("[MLB] No recent games to display") - self.last_warning_time = current_time - return # Skip display update entirely - - try: - current_time = time.time() - - # Check if it's time to switch games - if len(self.recent_games) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - # Move to next game - self.current_game_index = (self.current_game_index + 1) % len(self.recent_games) - self.current_game = self.recent_games[self.current_game_index] - self.last_game_switch = current_time - force_clear = True # Force clear when switching games - - # Log team switching - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - self.logger.info(f"[MLB Recent] Showing {away_abbr} vs {home_abbr}") - - # Create and display the game image - game_image = self._create_game_display(self.current_game) - self.display_manager.image = game_image - self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) - self.display_manager.update_display() - - except Exception as e: - logger.error(f"[MLB] Error displaying recent game: {e}", exc_info=True) class MLBUpcomingManager(BaseMLBManager, SportsUpcoming): """Manager for displaying upcoming MLB games.""" - def __init__(self, config: Dict[str, Any], display_manager, cache_manager: CacheManager): + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): super().__init__(config, display_manager, cache_manager) - self.logger.info("Initialized MLB Upcoming Manager") - self.upcoming_games = [] - self.current_game = None - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.mlb_config.get('upcoming_update_interval', 3600) - self.upcoming_games_to_show = self.mlb_config.get('upcoming_games_to_show', 10) # Show next 10 games - self.last_warning_time = 0 - self.warning_cooldown = 300 # Only show warning every 5 minutes - self.last_game_switch = 0 # Track when we last switched games - self.game_display_duration = 10 # Display each game for 10 seconds - logger.info(f"Initialized MLBUpcomingManager with {len(self.favorite_teams)} favorite teams") - self.last_log_time = 0 - self.log_interval = 300 # 5 minutes - - def update(self): - """Update upcoming games data.""" - current_time = time.time() - # Log config state for debugging - self.logger.debug(f"[MLB] show_favorite_teams_only: {self.mlb_config.get('show_favorite_teams_only', False)}") - self.logger.debug(f"[MLB] favorite_teams: {self.favorite_teams}") - if self.last_update != 0 and (current_time - self.last_update < self.update_interval): - return - - try: - # Fetch data from MLB API - games = self._fetch_mlb_api_data() - if not games: - self.logger.warning("[MLB] No games returned from API for upcoming games update.") - return - - # --- Optimization: Filter for favorite teams before processing --- - if self.mlb_config.get("show_favorite_teams_only", False): - games = { - game_id: game for game_id, game in games.items() - if game['home_team'] in self.favorite_teams or game['away_team'] in self.favorite_teams - } - self.logger.info(f"[MLB Upcoming] Filtered to {len(games)} games for favorite teams.") - - # Process games - new_upcoming_games = [] - - self.logger.info(f"[MLB] Processing {len(games)} games for upcoming games...") - - for game_id, game in games.items(): - self.logger.debug(f"[MLB] Processing game {game_id} for upcoming games...") - # Only fetch odds for games that will be displayed - is_favorite_game = (game['home_team'] in self.favorite_teams or - game['away_team'] in self.favorite_teams) - game_time = datetime.fromisoformat(game['start_time'].replace('Z', '+00:00')) - if game_time.tzinfo is None: - game_time = game_time.replace(tzinfo=timezone.utc) - if is_favorite_game: - self.logger.info(f"[MLB] Favorite team game found: {game['away_team']} @ {game['home_team']} at {game_time}") - else: - self.logger.debug(f"[MLB] Non-favorite team game: {game['away_team']} @ {game['home_team']} at {game_time}") - self.logger.info(f"[MLB] Game status: {game['status']}, State: {game['status_state']}") - is_upcoming = ( - game['status_state'] not in ['post', 'final', 'completed'] and - game_time > datetime.now(timezone.utc) - ) - self.logger.info(f"[MLB] Is upcoming: {is_upcoming}") - if is_upcoming: - # The redundant favorite team check from before is already covered by the initial filter - self.logger.info(f"[MLB] Adding game {game_id} to upcoming games list.") - self._fetch_odds(game) - new_upcoming_games.append(game) - else: - self.logger.info(f"[MLB] Skipping game {game_id} - not upcoming.") - - # Sort by game time (soonest first) and apply per-team logic - new_upcoming_games.sort(key=lambda x: x['start_time']) - - # If showing favorite teams only, select one game per team - if self.mlb_config.get("show_favorite_teams_only", False): - # Select one game per favorite team (earliest upcoming game for each team) - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - team_specific_games = [game for game in new_upcoming_games - if game['home_team'] == team or game['away_team'] == team] - - if team_specific_games: - # Take the earliest (first in sorted list) - team_games.append(team_specific_games[0]) - - # Sort the final list by game time - team_games.sort(key=lambda x: x['start_time']) - new_upcoming_games = team_games - else: - # Limit to configured number if not using favorite teams only - new_upcoming_games = new_upcoming_games[:self.upcoming_games_to_show] - - if new_upcoming_games: - logger.info(f"[MLB] Found {len(new_upcoming_games)} upcoming games for favorite teams") - self.upcoming_games = new_upcoming_games - if not self.current_game: - self.current_game = self.upcoming_games[0] - else: - logger.info("[MLB] No upcoming games found for favorite teams") - self.upcoming_games = [] - self.current_game = None - - self.last_update = current_time - - except Exception as e: - logger.error(f"[MLB] Error updating upcoming games: {e}", exc_info=True) - - def display(self, force_clear: bool = False): - """Display upcoming games.""" - if not self.upcoming_games: - current_time = time.time() - if current_time - self.last_warning_time > self.warning_cooldown: - logger.info("[MLB] No upcoming games to display") - self.last_warning_time = current_time - return # Skip display update entirely - - try: - current_time = time.time() - - # Check if it's time to switch games - if len(self.upcoming_games) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - # Move to next game - self.current_game_index = (self.current_game_index + 1) % len(self.upcoming_games) - self.current_game = self.upcoming_games[self.current_game_index] - self.last_game_switch = current_time - force_clear = True # Force clear when switching games - - # Log team switching - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - self.logger.info(f"[MLB Upcoming] Showing {away_abbr} vs {home_abbr}") - - # Create and display the game image - game_image = self._create_game_display(self.current_game) - self.display_manager.image = game_image - self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) - self.display_manager.update_display() - - except Exception as e: - logger.error(f"[MLB] Error displaying upcoming game: {e}", exc_info=True) \ No newline at end of file + self.logger = logging.getLogger("MLBUpcomingManager") # Changed logger name + self.logger.info( + f"Initialized MLBUpcomingManager with {len(self.favorite_teams)} favorite teams" + ) # Changed log prefix diff --git a/src/ncaa_baseball_managers.py b/src/ncaa_baseball_managers.py index 62a7c76c..0556f1e8 100644 --- a/src/ncaa_baseball_managers.py +++ b/src/ncaa_baseball_managers.py @@ -1,555 +1,135 @@ -import time import logging -import requests -import json -from typing import Dict, Any, List, Optional +import time from datetime import datetime, timedelta, timezone -import os -from PIL import Image, ImageDraw, ImageFont -import numpy as np -from src.cache_manager import CacheManager -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry -from src.odds_manager import OddsManager +from pathlib import Path +from typing import Any, Dict, Optional + import pytz +from PIL import Image, ImageDraw # Import baseball and standard sports classes from src.base_classes.baseball import Baseball, BaseballLive from src.base_classes.sports import SportsRecent, SportsUpcoming - -# Get logger -logger = logging.getLogger(__name__) +from src.cache_manager import CacheManager +from src.display_manager import DisplayManager # Constants for NCAA Baseball API URL ESPN_NCAABB_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/scoreboard" class BaseNCAABaseballManager(Baseball): """Base class for NCAA Baseball managers using new baseball architecture.""" - def __init__(self, config: Dict[str, Any], display_manager, cache_manager: CacheManager): - # Initialize with sport_key for NCAA Baseball - super().__init__(config, display_manager, cache_manager, logger, "ncaa_baseball") + def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): + # Initialize with sport_key for NCAABB + self.logger = logging.getLogger("NCAA Baseball") + super().__init__(config, display_manager, cache_manager, self.logger, "ncaa_baseball") # NCAA Baseball-specific configuration - self.ncaa_baseball_config = config.get('ncaa_baseball_scoreboard', {}) - self.show_odds = self.ncaa_baseball_config.get('show_odds', False) - self.show_records = self.ncaa_baseball_config.get('show_records', False) - self.favorite_teams = self.ncaa_baseball_config.get('favorite_teams', []) + self.show_odds = self.mode_config.get("show_odds", False) + self.favorite_teams = self.mode_config.get('favorite_teams', []) + self.show_records = self.mode_config.get('show_records', False) self.league = "college-baseball" - # Store reference to config instead of creating new ConfigManager - self.config_manager = None # Not used in this class - self.odds_manager = OddsManager(self.cache_manager, self.config_manager) - - # Logo handling - self.logo_dir = self.ncaa_baseball_config.get('logo_dir', os.path.join('assets', 'sports', 'ncaa_logos')) - if not os.path.exists(self.logo_dir): - self.logger.warning(f"NCAA Baseball logos directory not found: {self.logo_dir}") - try: - os.makedirs(self.logo_dir, exist_ok=True) - self.logger.info(f"Created NCAA Baseball logos directory: {self.logo_dir}") - except Exception as e: - self.logger.error(f"Failed to create NCAA Baseball logos directory: {e}") - - # Set up session with retry logic - self.session = requests.Session() - retry_strategy = Retry( - total=3, - 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) - self.headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' - } - - def _get_team_logo(self, team_abbr: str) -> Optional[Image.Image]: - """Get team logo from the configured directory or generate a fallback.""" - try: - logo_path = os.path.join(self.logo_dir, f"{team_abbr}.png") - if os.path.exists(logo_path): - img = Image.open(logo_path) - if img.mode != 'RGBA': - img = img.convert('RGBA') - return img - else: - logger.warning(f"[NCAABaseball] Logo not found for team {team_abbr}. Generating fallback.") - # Create a fallback image with the team abbreviation, ensure it's RGBA - logo_size = (32,32) # default size for fallback - image = Image.new('RGBA', logo_size, color=(0, 0, 0, 255)) # RGBA with full opacity - draw = ImageDraw.Draw(image) - - # Attempt to use a small, clear font - try: - font_path = "assets/fonts/PressStart2P-Regular.ttf" - # Adjust font size dynamically or pick a generally good small size - # For small logos (e.g., 32x32 or 35x35), a font size of 8-12 might work - # Max 3 chars like "LSU" - font_size = 0 - if logo_size[0] < 20 or len(team_abbr) > 3: # very small logo or long abbr - font_size = 6 - elif len(team_abbr) > 2: - font_size = 8 - else: - font_size = 10 - - if not os.path.exists(font_path): # Fallback if PressStart2P is missing - font_path = "arial.ttf" # try a common system font - font_size = logo_size[1] // 3 # Adjust size for arial - - font = ImageFont.truetype(font_path, font_size) - except IOError: - logger.warning(f"Font {font_path} not found. Using default font for fallback logo.") - font = ImageFont.load_default() # Fallback to default PIL font - # For default font, textbbox might not be available or behave differently - # We'll estimate text size or accept potentially non-centered text. - - try: - # Get text dimensions using textbbox if available - text_bbox = draw.textbbox((0, 0), team_abbr, font=font) - text_width = text_bbox[2] - text_bbox[0] - text_height = text_bbox[3] - text_bbox[1] - except AttributeError: - # Fallback for older PIL/Pillow or default font if textbbox is not present - text_width = len(team_abbr) * (font_size // 1.5 if hasattr(font, 'size') else 6) # Rough estimate - text_height = font_size if hasattr(font, 'size') else 8 # Rough estimate - - - x = (logo_size[0] - text_width) / 2 - y = (logo_size[1] - text_height) / 2 - - self._draw_text_with_outline(draw, team_abbr, (x, y), font) - return image - except Exception as e: - logger.error(f"[NCAABaseball] Error loading or generating logo for team {team_abbr}: {e}") - return None - - def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): + def _fetch_ncaa_baseball_api_data(self, use_cache: bool = True) -> Optional[Dict]: """ - Draw text with a black outline for better readability. + Fetches the full season schedule for NCAA Baseball using week-by-week approach to ensure + we get all games, then caches the complete dataset. - Args: - draw: ImageDraw object - text: Text to draw - position: (x, y) position to draw the text - font: Font to use - fill: Text color (default: white) - outline_color: Outline color (default: black) + This method now uses background threading to prevent blocking the display. """ - x, y = position - - # Draw the outline by drawing the text in black at 8 positions around the text - 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 the text in the specified color - draw.text((x, y), text, font=font, fill=fill) + now = datetime.now(pytz.utc) + start_of_last_month = now.replace(day=1, month=now.month - 1) + last_day_of_next_month = now.replace(day=1, month=now.month + 2) - timedelta(days=1) + start_of_last_month_str = start_of_last_month.strftime("%Y%m%d") + last_day_of_next_month_str = last_day_of_next_month.strftime("%Y%m%d") + datestring = f"{start_of_last_month_str}-{last_day_of_next_month_str}" + cache_key = f"ncaa_baseball_schedule_{datestring}" - def _draw_base_indicators(self, draw: ImageDraw.Draw, bases_occupied: List[bool], center_x: int, y: int) -> None: - """Draw base indicators on the display.""" - base_size = 8 - base_spacing = 10 - diamond_points = [ - (center_x, y), (center_x - base_spacing, y - base_spacing), - (center_x, y - 2 * base_spacing), (center_x + base_spacing, y - base_spacing) - ] - for i in range(len(diamond_points)): - start = diamond_points[i] - end = diamond_points[(i + 1) % len(diamond_points)] - draw.line([start, end], fill=(255, 255, 255), width=2) - for i, occupied in enumerate(bases_occupied): - x = diamond_points[i+1][0] - base_size//2 - y = diamond_points[i+1][1] - base_size//2 - if occupied: - draw.ellipse([x-1, y-1, x + base_size+1, y + base_size+1], fill=(255, 255, 255)) - draw.ellipse([x+1, y+1, x + base_size-1, y + base_size-1], fill=(0, 0, 0)) - else: - draw.ellipse([x, y, x + base_size, y + base_size], outline=(255, 255, 255), width=1) - - def _create_game_display(self, game_data: Dict[str, Any]) -> Image.Image: - """Create a display image for an NCAA Baseball game with team logos, score, and game state.""" - width = self.display_manager.matrix.width - height = self.display_manager.matrix.height - image = Image.new('RGB', (width, height), color=(0, 0, 0)) - - # Make logos 150% of display dimensions to allow them to extend off screen - max_width = int(width * 1.5) - max_height = int(height * 1.5) - - away_logo = self._get_team_logo(game_data['away_team']) - home_logo = self._get_team_logo(game_data['home_team']) - - if away_logo and home_logo: - # Resize maintaining aspect ratio - away_logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - home_logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - - # Create a single overlay for both logos - overlay = Image.new('RGBA', (width, height), (0, 0, 0, 0)) - - # Calculate vertical center line for alignment - center_y = height // 2 - - # Draw home team logo (far right, extending beyond screen) - home_x = width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - - # Paste the home logo onto the overlay - overlay.paste(home_logo, (home_x, home_y), home_logo) - - # Draw away team logo (far left, extending beyond screen) - away_x = -2 - away_y = center_y - (away_logo.height // 2) - - overlay.paste(away_logo, (away_x, away_y), away_logo) - - # Composite the overlay with the main image - image = image.convert('RGBA') - image = Image.alpha_composite(image, overlay) - image = image.convert('RGB') - - draw = ImageDraw.Draw(image) - - # For upcoming games, show date and time stacked in the center - if game_data['status'] == 'status_scheduled': - status_text = "Next Game" - self.display_manager.calendar_font.set_char_size(height=7*64) - status_width = self.display_manager.get_text_width(status_text, self.display_manager.calendar_font) - status_x = (width - status_width) // 2 - status_y = 2 - self.display_manager.draw = draw - script_dir = os.path.dirname(os.path.abspath(__file__)) - font_4x6 = os.path.abspath(os.path.join(script_dir, "../assets/fonts/4x6-font.ttf")) - status_font = ImageFont.truetype(font_4x6, 6) # Using a default small font - self._draw_text_with_outline(draw, status_text, (status_x, status_y), status_font) - - game_time = datetime.fromisoformat(game_data['start_time'].replace('Z', '+00:00')) - timezone_str = self.config.get('timezone', 'UTC') - try: - tz = pytz.timezone(timezone_str) - except pytz.exceptions.UnknownTimeZoneError: - logger.warning(f"[NCAABaseball] Unknown timezone: {timezone_str}, falling back to UTC") - tz = pytz.UTC - if game_time.tzinfo is None: - game_time = game_time.replace(tzinfo=pytz.UTC) - local_time = game_time.astimezone(tz) - - # 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: - game_date = self.display_manager.format_date_with_ordinal(local_time) - - game_time_str = self._format_game_time(game_data['start_time']) - - ps2p = os.path.abspath(os.path.join(script_dir, "../assets/fonts/PressStart2P-Regular.ttf")) - date_font = ImageFont.truetype(ps2p, 8) - time_font = ImageFont.truetype(ps2p, 8) - - date_width = draw.textlength(game_date, font=date_font) - date_x = (width - date_width) // 2 - date_y = (height - date_font.getmetrics()[0]) // 2 - 3 # Adjusted for font metrics - self._draw_text_with_outline(draw, game_date, (date_x, date_y), date_font) - - time_width = draw.textlength(game_time_str, font=time_font) - time_x = (width - time_width) // 2 - time_y = date_y + 10 - self._draw_text_with_outline(draw, game_time_str, (time_x, time_y), time_font) - - # For recent/final games, show scores and status - elif game_data['status'] in ['status_final', 'final', 'completed']: - status_text = "Final" - status_font = ImageFont.truetype(font_4x6, 6) # Using a default small font - status_width = draw.textlength(status_text, font=status_font) - status_x = (width - status_width) // 2 - status_y = 2 - self.display_manager.draw = draw - self._draw_text_with_outline(draw, status_text, (status_x, status_y), status_font) - - away_score = str(game_data['away_score']) - home_score = str(game_data['home_score']) - score_text = f"{away_score}-{home_score}" - score_font = ImageFont.truetype(ps2p, 12) - - score_width = draw.textlength(score_text, font=score_font) - score_x = (width - score_width) // 2 - score_y = height - score_font.getmetrics()[0] - 2 # Adjusted for font metrics - self._draw_text_with_outline(draw, score_text, (score_x, score_y), score_font) - - if self.show_records and game_data['status'] in ['status_scheduled', 'status_final', 'final', 'completed']: - try: - record_font = ImageFont.truetype(font_4x6, 6) - except IOError: - record_font = ImageFont.load_default() - - away_record = game_data.get('away_record', '') - home_record = game_data.get('home_record', '') - - record_bbox = draw.textbbox((0, 0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = height - record_height - - if away_record: - away_record_x = 0 - self._draw_text_with_outline(draw, away_record, (away_record_x, record_y), record_font) - - if home_record: - home_record_bbox = draw.textbbox((0, 0), home_record, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = width - home_record_width - self._draw_text_with_outline(draw, home_record, (home_record_x, record_y), record_font) - - # Draw betting odds if available and enabled - if self.show_odds and 'odds' in game_data: - odds_details = game_data['odds'].get('details', 'N/A') - home_team_odds = game_data['odds'].get('home_team_odds', {}) - away_team_odds = game_data['odds'].get('away_team_odds', {}) - - # Extract spread and format it - home_spread = home_team_odds.get('point_spread', 'N/A') - away_spread = away_team_odds.get('point_spread', 'N/A') - - # Add a plus sign to positive spreads - if isinstance(home_spread, (int, float)) and home_spread > 0: - home_spread = f"+{home_spread}" - - if isinstance(away_spread, (int, float)) and away_spread > 0: - away_spread = f"+{away_spread}" - - # Define colors for odds text - # Use a small readable font for odds; fall back to default if not available - try: - odds_font = ImageFont.truetype(font_4x6, 6) - except IOError: - odds_font = ImageFont.load_default() - odds_color = (255, 0, 0) # Red text - outline_color = (0, 0, 0) # Black outline - - # Draw away team odds - if away_spread != 'N/A': - away_odds_x = 0 - away_odds_y = 0 - self._draw_text_with_outline(draw, str(away_spread), (away_odds_x, away_odds_y), odds_font, odds_color, outline_color) - - # Draw home team odds - if home_spread != 'N/A': - home_odds_x = width - draw.textlength(str(home_spread), font=odds_font) - home_odds_y = 0 - self._draw_text_with_outline(draw, str(home_spread), (home_odds_x, home_odds_y), odds_font, odds_color, outline_color) - - return image - - def _format_game_time(self, game_time: str) -> str: - """Format game time for display.""" - try: - timezone_str = self.config.get('timezone', 'UTC') - try: - tz = pytz.timezone(timezone_str) - except pytz.exceptions.UnknownTimeZoneError: - logger.warning(f"[NCAABaseball] Unknown timezone: {timezone_str}, falling back to UTC") - tz = pytz.UTC - dt = datetime.fromisoformat(game_time.replace('Z', '+00:00')) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=pytz.UTC) - local_dt = dt.astimezone(tz) - return local_dt.strftime("%I:%M%p").lstrip('0') - except Exception as e: - logger.error(f"[NCAABaseball] Error formatting game time: {e}") - return "TBD" - - def _fetch_ncaa_baseball_api_data(self, use_cache: bool = True) -> Dict[str, Any]: - """Fetch NCAA Baseball game data from the ESPN API.""" - cache_key = "ncaa_baseball_api_data" if use_cache: - cached_data = self.cache_manager.get_with_auto_strategy(cache_key) + cached_data = self.cache_manager.get(cache_key) if cached_data: - self.logger.info("Using cached NCAA Baseball API data.") - return cached_data + # Validate cached data structure + if isinstance(cached_data, dict) and 'events' in cached_data: + self.logger.info(f"Using cached schedule for {datestring}") + return cached_data + elif isinstance(cached_data, list): + # Handle old cache format (list of events) + self.logger.info(f"Using cached schedule for {datestring} (legacy format)") + return {'events': cached_data} + else: + self.logger.warning(f"Invalid cached data format for {datestring}: {type(cached_data)}") + # Clear invalid cache + self.cache_manager.clear_cache(cache_key) + + # If background service is disabled, fall back to synchronous fetch + if not self.background_enabled or not self.background_service: + pass + # return self._fetch_ncaa_api_data_sync(use_cache) + + self.logger.info(f"Fetching full {datestring} season schedule from ESPN API...") - try: - # Check if test mode is enabled - if self.ncaa_baseball_config.get('test_mode', False): - self.logger.info("Using test mode data for NCAA Baseball") - return { - 'test_game_ncaabaseball_1': { - 'away_team': 'LSU', - 'home_team': 'FLA', - 'away_score': 5, - 'home_score': 4, - 'status': 'in', - 'status_state': 'in', - 'inning': 8, - 'inning_half': 'top', - 'balls': 1, - 'strikes': 2, - 'outs': 2, - 'bases_occupied': [True, True, False], - 'start_time': datetime.now(timezone.utc).isoformat() - } - } + # Start background fetch + self.logger.info(f"Starting background fetch for {datestring} season schedule...") + + def fetch_callback(result): + """Callback when background fetch completes.""" + if result.success: + self.logger.info(f"Background fetch completed for {datestring}: {len(result.data.get('events'))} events") + else: + self.logger.error(f"Background fetch failed for {datestring}: {result.error}") - now = datetime.now(timezone.utc) - yesterday = now - timedelta(days=1) - tomorrow = now + timedelta(days=1) - - dates = [ - yesterday.strftime("%Y%m%d"), - now.strftime("%Y%m%d"), - tomorrow.strftime("%Y%m%d") - ] - - all_games = {} - - for date in dates: - # Use NCAA Baseball API URL - url = f"{ESPN_NCAABB_SCOREBOARD_URL}?dates={date}" - - self.logger.info(f"[NCAABaseball] Fetching games from ESPN API for date: {date}") - response = self.session.get(url, headers=self.headers, timeout=10) - response.raise_for_status() - - data = response.json() - - for event in data.get('events', []): - game_id = event['id'] - status = event['status']['type']['name'].lower() - status_state = event['status']['type']['state'].lower() - - competitors = event['competitions'][0]['competitors'] - home_team = next((c for c in competitors if c['homeAway'] == 'home'), None) - away_team = next((c for c in competitors if c['homeAway'] == 'away'), None) + # Clean up request tracking + if datestring in self.background_fetch_requests: + del self.background_fetch_requests[datestring] + + # 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="ncaa_baseball", + year=now.year, + url=ESPN_NCAABB_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[datestring] = request_id + + # For immediate response, try to get partial data + partial_data = self._get_weeks_data() + if partial_data: + return partial_data + return None - if not home_team or not away_team: - self.logger.warning(f"[NCAABaseball] Could not find home or away team for event {game_id}") - continue - home_abbr = home_team['team'].get('abbreviation', 'N/A') - away_abbr = away_team['team'].get('abbreviation', 'N/A') - 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 '' - - # Don't show "0-0" records - set to blank instead - if home_record == "0-0": - home_record = '' - if away_record == "0-0": - away_record = '' - - is_favorite_game = (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams) - - if is_favorite_game: - self.logger.info(f"[NCAABaseball] Found favorite team game: {away_abbr} @ {home_abbr} (Status: {status}, State: {status_state})") - self.logger.debug(f"[NCAABaseball] Full status data: {event['status']}") - self.logger.debug(f"[NCAABaseball] Status type: {status}, State: {status_state}") - self.logger.debug(f"[NCAABaseball] Status detail: {event['status'].get('detail', '')}") - self.logger.debug(f"[NCAABaseball] Status shortDetail: {event['status'].get('shortDetail', '')}") - - inning = 1 - inning_half = 'top' - balls = 0 - strikes = 0 - outs = 0 - bases_occupied = [False, False, False] - - if status_state == 'in': - inning = event['status'].get('period', 1) - status_detail = event['status'].get('detail', '').lower() - status_short = event['status'].get('shortDetail', '').lower() - - if is_favorite_game: self.logger.debug(f"[NCAABaseball] Raw status detail: {event['status'].get('detail')}") - if is_favorite_game: self.logger.debug(f"[NCAABaseball] Raw status short: {event['status'].get('shortDetail')}") - - inning_half = 'top' - if '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("[NCAABaseball] Detected bottom of inning") - elif 'top' in status_detail or 'mid' in status_detail or 'top' in status_short or 'mid' in status_short: - inning_half = 'top' - if is_favorite_game: self.logger.debug("[NCAABaseball] Detected top of inning") - - if is_favorite_game: self.logger.debug(f"[NCAABaseball] Determined inning: {inning_half} {inning}") - - situation = event['competitions'][0].get('situation', {}) - if is_favorite_game: self.logger.debug(f"[NCAABaseball] Full situation data: {situation}") - - # --- Simplified Count Logic --- - # Primarily rely on the direct count fields first - count = situation.get('count', {}) - balls = count.get('balls', 0) - strikes = count.get('strikes', 0) - outs = situation.get('outs', 0) - - # Basic logging - if is_favorite_game: - self.logger.debug(f"[NCAABaseball] Direct count: B={balls}, S={strikes}, O={outs}") - - # Keep base occupancy logic - bases_occupied = [ - situation.get('onFirst', False), - situation.get('onSecond', False), - situation.get('onThird', False) - ] - if is_favorite_game: self.logger.debug(f"[NCAABaseball] Bases occupied: {bases_occupied}") - - all_games[game_id] = { - 'away_team': away_abbr, - 'home_team': home_abbr, - 'away_score': away_team.get('score', '0'), - 'home_score': home_team.get('score', '0'), - 'away_record': away_record, - 'home_record': home_record, - 'status': status, - 'status_state': status_state, - 'inning': inning, - 'inning_half': inning_half, - 'balls': balls, - 'strikes': strikes, - 'outs': outs, - 'bases_occupied': bases_occupied, - 'start_time': event['date'] - } - - favorite_games = [game for game in all_games.values() - if game['home_team'] in self.favorite_teams or - game['away_team'] in self.favorite_teams] - if favorite_games: - self.logger.info(f"[NCAABaseball] Found {len(favorite_games)} games for favorite teams: {self.favorite_teams}") - for game in favorite_games: - self.logger.info(f"[NCAABaseball] Favorite team game: {game['away_team']} @ {game['home_team']} (Status: {game['status']}, State: {game['status_state']})") - - if use_cache: - self.cache_manager.set(cache_key, all_games) - return all_games - - except Exception as e: - self.logger.error(f"[NCAABaseball] Error fetching NCAA Baseball data from ESPN API: {e}", exc_info=True) - return {} + 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 displaying live NCAA Baseball games.""" def __init__(self, config: Dict[str, Any], display_manager, cache_manager: CacheManager): super().__init__(config, display_manager, cache_manager) self.logger.info("Initialized NCAA Baseball Live Manager") - self.live_games = [] - self.current_game = None - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.ncaa_baseball_config.get('live_update_interval', 20) - self.no_data_interval = 300 - self.last_game_switch = 0 - self.game_display_duration = self.ncaa_baseball_config.get('live_game_duration', 30) - self.last_display_update = 0 - self.last_log_time = 0 - self.log_interval = 300 - self.last_count_log_time = 0 - self.count_log_interval = 5 - self.test_mode = self.ncaa_baseball_config.get('test_mode', False) if self.test_mode: self.current_game = { - "home_team": "FLA", - "away_team": "LSU", + "home_abbr": "FLA", + "home_id": "234", + "away_abbr": "LSU", + "away_id": "234", "home_score": "4", "away_score": "5", "status": "live", @@ -560,532 +140,27 @@ class NCAABaseballLiveManager(BaseNCAABaseballManager, BaseballLive): "strikes": 2, "outs": 2, "bases_occupied": [True, True, False], - "home_logo_path": os.path.join(self.logo_dir, "FLA.png"), - "away_logo_path": os.path.join(self.logo_dir, "LSU.png"), + "home_logo_path": Path(self.logo_dir, "FLA.png"), + "away_logo_path": Path(self.logo_dir, "LSU.png"), "start_time": datetime.now(timezone.utc).isoformat(), + "is_live": True, "is_final": False, "is_upcoming": False, } self.live_games = [self.current_game] self.logger.info("Initialized NCAABaseballLiveManager with test game: LSU vs FLA") else: self.logger.info("Initialized NCAABaseballLiveManager in live mode") - def update(self): - """Update live game data.""" - current_time = time.time() - interval = self.no_data_interval if not self.live_games else self.update_interval - - if current_time - self.last_update >= interval: - self.last_update = current_time - - if self.test_mode: - if self.current_game: - 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) - else: - games = self._fetch_ncaa_baseball_api_data(use_cache=False) - if games: - new_live_games = [] - for game in games.values(): - if game['status_state'] == 'in': - # Filter for favorite teams only if the config is set - if self.ncaa_baseball_config.get("show_favorite_teams_only", False): - if not (game['home_team'] in self.favorite_teams or game['away_team'] in self.favorite_teams): - continue - - self._fetch_odds(game) - try: - game['home_score'] = int(game['home_score']) - game['away_score'] = int(game['away_score']) - new_live_games.append(game) - except (ValueError, TypeError): - self.logger.warning(f"[NCAABaseball] Invalid score format for game {game['away_team']} @ {game['home_team']}") - - should_log = ( - current_time - self.last_log_time >= self.log_interval or - len(new_live_games) != len(self.live_games) or - not self.live_games - ) - - if should_log: - if new_live_games: - filter_text = "favorite teams" if self.ncaa_baseball_config.get("show_favorite_teams_only", False) else "all teams" - logger.info(f"[NCAABaseball] Found {len(new_live_games)} live games involving {filter_text}") - for game in new_live_games: - logger.info(f"[NCAABaseball] Live game: {game['away_team']} vs {game['home_team']} - {game['inning_half']}{game['inning']}, {game['balls']}-{game['strikes']}") - else: - filter_text = "favorite teams" if self.ncaa_baseball_config.get("show_favorite_teams_only", False) else "criteria" - logger.info(f"[NCAABaseball] No live games found matching {filter_text}") - self.last_log_time = current_time - - if new_live_games: - current_game_found_in_new = False - if self.current_game: - current_id = self.current_game.get('id') - for i, new_game in enumerate(new_live_games): - if new_game.get('id') == current_id: - self.current_game = new_game - self.current_game_index = i - current_game_found_in_new = True - break - - if not self.live_games or set(g.get('id') for g in new_live_games) != set(g.get('id') for g in self.live_games): - self.live_games = sorted(new_live_games, key=lambda g: g.get('start_time')) - if not current_game_found_in_new: - self.current_game_index = 0 - self.current_game = self.live_games[0] if self.live_games else None - self.last_game_switch = current_time - - # Only update display if we have new data and enough time has passed - if current_time - self.last_display_update >= 1.0: - # self.display(force_clear=True) # REMOVED: DisplayController handles this - self.last_display_update = current_time - else: - self.live_games = [] - self.current_game = None - - if len(self.live_games) > 1 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 - - # Log team switching - if self.current_game: - away_team = self.current_game.get('away_team', 'UNK') - home_team = self.current_game.get('home_team', 'UNK') - self.logger.info(f"[NCAABASEBALL Live] Showing {away_team} vs {home_team}") - - # Force display update when switching games - # self.display(force_clear=True) # REMOVED: DisplayController handles this - self.last_display_update = current_time # Track last successful update that *would* have displayed - - def _create_live_game_display(self, game_data: Dict[str, Any]) -> Image.Image: - """Create a display image for a live NCAA Baseball game.""" - width = self.display_manager.matrix.width - height = self.display_manager.matrix.height - image = Image.new('RGB', (width, height), color=(0, 0, 0)) - - # Make logos 150% of display dimensions to allow them to extend off screen - max_width = int(width * 1.5) - max_height = int(height * 1.5) - - away_logo = self._get_team_logo(game_data['away_team']) - home_logo = self._get_team_logo(game_data['home_team']) - - if away_logo and home_logo: - # Resize maintaining aspect ratio - away_logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - home_logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - - # Create a single overlay for both logos - overlay = Image.new('RGBA', (width, height), (0, 0, 0, 0)) - - # Calculate vertical center line for alignment - center_y = height // 2 - - # Draw home team logo (far right, extending beyond screen) - home_x = width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - - # Paste the home logo onto the overlay - overlay.paste(home_logo, (home_x, home_y), home_logo) - - # Draw away team logo (far left, extending beyond screen) - away_x = -2 - away_y = center_y - (away_logo.height // 2) - - overlay.paste(away_logo, (away_x, away_y), away_logo) - - # Composite the overlay with the main image - image = image.convert('RGBA') - image = Image.alpha_composite(image, overlay) - image = image.convert('RGB') - - draw = ImageDraw.Draw(image) - - # --- Live Game Specific Elements --- - - # Define default text color - text_color = (255, 255, 255) - - inning_half_indicator = "▲" if game_data['inning_half'] == 'top' else "▼" - inning_text = f"{inning_half_indicator}{game_data['inning']}" - inning_bbox = draw.textbbox((0, 0), inning_text, font=self.display_manager.font) - inning_width = inning_bbox[2] - inning_bbox[0] - inning_x = (width - inning_width) // 2 - inning_y = 1 # Position near top center - self._draw_text_with_outline(draw, inning_text, (inning_x, inning_y), self.display_manager.font) - - # --- REVISED BASES AND OUTS DRAWING --- - bases_occupied = game_data['bases_occupied'] - outs = game_data.get('outs', 0) - inning_half = game_data['inning_half'] - 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] + 0 - bases_origin_x = (width - base_cluster_width) // 2 - 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) - base_color_occupied = (255, 255, 255); base_color_empty = (255, 255, 255); h_d = base_diamond_size // 2 - 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_occupied) - else: - draw.polygon(poly2, outline=base_color_empty) - base_bottom_y = c2y + h_d - 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_occupied) - else: - draw.polygon(poly3, outline=base_color_empty) - 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_occupied) - else: - draw.polygon(poly1, outline=base_color_empty) - 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.ellipse(coords, fill=circle_color_out) - else: - draw.ellipse(coords, outline=circle_color_empty_outline) - - balls = game_data.get('balls', 0) - strikes = game_data.get('strikes', 0) - current_time = time.time() - if (game_data['home_team'] in self.favorite_teams or game_data['away_team'] in self.favorite_teams) and current_time - self.last_count_log_time >= self.count_log_interval: - self.logger.debug(f"[NCAABaseball] Displaying count: {balls}-{strikes}") - self.logger.debug(f"[NCAABaseball] Raw count data: balls={game_data.get('balls')}, strikes={game_data.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) - count_text_width = self.display_manager.get_text_width(count_text, bdf_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_text_width) // 2 - self.display_manager.draw = draw - # self._draw_text_with_outline(draw, count_text, (count_x, count_y), bdf_font, fill=text_color) - - # Draw Balls-Strikes Count with outline using BDF font - outline_color_for_bdf = (0, 0, 0) # Default outline color - - # 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) - - score_font = self.display_manager.font; outline_color = (0, 0, 0); score_text_color = (255, 255, 255) - def draw_bottom_outlined_text(x, y, text): - self._draw_text_with_outline(draw, text, (x,y), score_font, fill=score_text_color, outline_color=outline_color) - away_abbr = game_data['away_team']; home_abbr = game_data['home_team'] - away_score_str = str(game_data['away_score']) - home_score_str = str(game_data['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 = height - font_height - 2 # 2 pixels padding from bottom - - # Away Team:Score (Bottom Left) - away_score_x = 2 # Padding from left - draw_bottom_outlined_text(away_score_x, score_y, away_text) - - # Home Team:Score (Bottom Right) - home_text_bbox = draw.textbbox((0,0), home_text, font=score_font) - home_text_width = home_text_bbox[2] - home_text_bbox[0] - home_score_x = width - home_text_width - 2 # Padding from right - draw_bottom_outlined_text(home_score_x, score_y, home_text) - - return image - - def display(self, force_clear: bool = False): - """Display live game information.""" - if not self.current_game: - return - try: - game_image = self._create_live_game_display(self.current_game) - self.display_manager.image = game_image - self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) - self.display_manager.update_display() - except Exception as e: - logger.error(f"[NCAABaseball] Error displaying live game: {e}", exc_info=True) class NCAABaseballRecentManager(BaseNCAABaseballManager, SportsRecent): """Manager for displaying recent NCAA Baseball games.""" - def __init__(self, config: Dict[str, Any], display_manager, cache_manager: CacheManager): + def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): super().__init__(config, display_manager, cache_manager) - self.logger.info("Initialized NCAA Baseball Recent Manager") - self.recent_games = [] - self.current_game = None - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.ncaa_baseball_config.get('recent_update_interval', 3600) - self.recent_games_to_show = self.ncaa_baseball_config.get('recent_games_to_show', 5) # Number of most recent games to display - self.last_game_switch = 0 - self.game_display_duration = 10 - self.last_warning_time = 0 - self.warning_cooldown = 300 - logger.info(f"Initialized NCAABaseballRecentManager with {len(self.favorite_teams)} favorite teams") - - def update(self): - """Update recent games data.""" - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - self.last_update = current_time - try: - games = self._fetch_ncaa_baseball_api_data(use_cache=True) - if not games: - logger.warning("[NCAABaseball] No games returned from API") - self.recent_games = [] - self.current_game = None - return - - new_recent_games = [] - - for game_id, game in games.items(): - game_time_str = game['start_time'].replace('Z', '+00:00') - game_time = datetime.fromisoformat(game_time_str) - if game_time.tzinfo is None: game_time = game_time.replace(tzinfo=timezone.utc) - - is_favorite_game = (game['home_team'] in self.favorite_teams or game['away_team'] in self.favorite_teams) - # Only filter by favorite teams if show_favorite_teams_only is True - if self.ncaa_baseball_config.get("show_favorite_teams_only", False) and not is_favorite_game: - continue - - logger.info(f"[NCAABaseball] Checking favorite recent game: {game['away_team']} @ {game['home_team']}") - logger.info(f"[NCAABaseball] Game time (UTC): {game_time}") - logger.info(f"[NCAABaseball] Game status: {game['status']}, State: {game['status_state']}") - - is_final = game['status_state'] in ['post', 'final', 'completed'] - - logger.info(f"[NCAABaseball] Is final: {is_final}") - - if is_final: - self._fetch_odds(game) - new_recent_games.append(game) - logger.info(f"[NCAABaseball] Added favorite team game to recent list: {game['away_team']} @ {game['home_team']}") - - # Filter for favorite teams only if the config is set - if self.ncaa_baseball_config.get("show_favorite_teams_only", False): - # Get all games involving favorite teams - favorite_team_games = [game for game in new_recent_games - if game['home_team'] in self.favorite_teams or - game['away_team'] in self.favorite_teams] - - # Select one game per favorite team (most recent game for each team) - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - team_specific_games = [game for game in favorite_team_games - if game['home_team'] == team or game['away_team'] == team] - - if team_specific_games: - # Sort by game time and take the most recent - team_specific_games.sort(key=lambda g: g.get('start_time'), reverse=True) - team_games.append(team_specific_games[0]) - - # Sort the final list by game time (most recent first) - team_games.sort(key=lambda g: g.get('start_time'), reverse=True) - else: - team_games = new_recent_games - # Sort by game time (most recent first), then limit to recent_games_to_show - team_games = sorted(team_games, key=lambda g: g.get('start_time'), reverse=True) - team_games = team_games[:self.recent_games_to_show] - - if team_games: - logger.info(f"[NCAABaseball] Found {len(team_games)} recent games for favorite teams (limited to {self.recent_games_to_show}): {self.favorite_teams}") - self.recent_games = team_games - if not self.current_game or self.current_game.get('id') not in [g.get('id') for g in self.recent_games]: - self.current_game_index = 0 - self.current_game = self.recent_games[0] if self.recent_games else None - else: - logger.info("[NCAABaseball] No recent games found for favorite teams") - self.recent_games = [] - self.current_game = None - - except Exception as e: - logger.error(f"[NCAABaseball] Error updating recent games: {e}", exc_info=True) - - def display(self, force_clear: bool = False): - """Display recent games.""" - if not self.recent_games: - current_time = time.time() - if current_time - self.last_warning_time > self.warning_cooldown: - logger.info("[NCAABaseball] No recent games to display") - self.last_warning_time = current_time - return - try: - current_time = time.time() - if len(self.recent_games) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - self.current_game_index = (self.current_game_index + 1) % len(self.recent_games) - self.current_game = self.recent_games[self.current_game_index] - self.last_game_switch = current_time - force_clear = True - - # Log team switching - if self.current_game: - away_team = self.current_game.get('away_team', 'UNK') - home_team = self.current_game.get('home_team', 'UNK') - logger.info(f"[NCAABASEBALL Recent] Showing {away_team} vs {home_team}") - - if self.current_game: - game_image = self._create_game_display(self.current_game) - self.display_manager.image = game_image - self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) - self.display_manager.update_display() - else: - logger.warning("[NCAABaseball] Current game is None, cannot display recent game.") - - except Exception as e: - logger.error(f"[NCAABaseball] Error displaying recent game: {e}", exc_info=True) + self.logger = logging.getLogger('NCAABaseballRecentManager') # Changed logger name + self.logger.info(f"Initialized NCAABaseballRecentManager with {len(self.favorite_teams)} favorite teams") # Changed log prefix class NCAABaseballUpcomingManager(BaseNCAABaseballManager, SportsUpcoming): """Manager for displaying upcoming NCAA Baseball games.""" - def __init__(self, config: Dict[str, Any], display_manager, cache_manager: CacheManager): + def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): super().__init__(config, display_manager, cache_manager) - self.logger.info("Initialized NCAA Baseball Upcoming Manager") - self.upcoming_games = [] - self.current_game = None - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.ncaa_baseball_config.get('upcoming_update_interval', 3600) - self.upcoming_games_to_show = self.ncaa_baseball_config.get('upcoming_games_to_show', 5) # Number of upcoming games to display - self.last_warning_time = 0 - self.warning_cooldown = 300 - self.last_game_switch = 0 - self.game_display_duration = 10 - logger.info(f"Initialized NCAABaseballUpcomingManager with {len(self.favorite_teams)} favorite teams") - - def update(self): - """Update upcoming games data.""" - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - self.last_update = current_time - try: - games = self._fetch_ncaa_baseball_api_data(use_cache=True) - if games: - new_upcoming_games = [] - now = datetime.now(timezone.utc) - - for game in games.values(): - is_favorite_game = (game['home_team'] in self.favorite_teams or game['away_team'] in self.favorite_teams) - # Only filter by favorite teams if show_favorite_teams_only is True - if self.ncaa_baseball_config.get("show_favorite_teams_only", False) and not is_favorite_game: - continue - - game_time = datetime.fromisoformat(game['start_time'].replace('Z', '+00:00')) - if game_time.tzinfo is None: game_time = game_time.replace(tzinfo=timezone.utc) - - logger.info(f"[NCAABaseball] Checking favorite upcoming game: {game['away_team']} @ {game['home_team']} at {game_time}") - logger.info(f"[NCAABaseball] Game status: {game['status']}, State: {game['status_state']}") - - is_upcoming_state = game['status_state'] not in ['post', 'final', 'completed'] and game['status'] == 'status_scheduled' - is_future = game_time > now - - logger.info(f"[NCAABaseball] Is upcoming state: {is_upcoming_state}") - logger.info(f"[NCAABaseball] Is future: {is_future}") - - if is_upcoming_state and is_future: - self._fetch_odds(game) - new_upcoming_games.append(game) - logger.info(f"[NCAABaseball] Added favorite team game to upcoming list: {game['away_team']} @ {game['home_team']}") - - # Filter for favorite teams only if the config is set - if self.ncaa_baseball_config.get("show_favorite_teams_only", False): - # Get all games involving favorite teams - favorite_team_games = [game for game in new_upcoming_games - if game['home_team'] in self.favorite_teams or - game['away_team'] in self.favorite_teams] - - # Select one game per favorite team (earliest upcoming game for each team) - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - team_specific_games = [game for game in favorite_team_games - if game['home_team'] == team or game['away_team'] == team] - - if team_specific_games: - # Sort by game time and take the earliest - team_specific_games.sort(key=lambda g: g.get('start_time')) - team_games.append(team_specific_games[0]) - - # Sort the final list by game time - team_games.sort(key=lambda g: g.get('start_time')) - else: - team_games = new_upcoming_games - # Sort by game time (soonest first), then limit to configured count - team_games = sorted(team_games, key=lambda g: g.get('start_time')) - team_games = team_games[:self.upcoming_games_to_show] - - if team_games: - logger.info(f"[NCAABaseball] Found {len(team_games)} upcoming games for favorite teams (limited to {self.upcoming_games_to_show})") - self.upcoming_games = team_games - if not self.current_game or self.current_game.get('id') not in [g.get('id') for g in self.upcoming_games]: - self.current_game_index = 0 - self.current_game = self.upcoming_games[0] if self.upcoming_games else None - else: - logger.info("[NCAABaseball] No upcoming games found for favorite teams") - self.upcoming_games = [] - self.current_game = None - - except Exception as e: - logger.error(f"[NCAABaseball] Error updating upcoming games: {e}", exc_info=True) - - def display(self, force_clear: bool = False): - """Display upcoming games.""" - if not self.upcoming_games: - current_time = time.time() - if current_time - self.last_warning_time > self.warning_cooldown: - logger.info("[NCAABaseball] No upcoming games to display") - self.last_warning_time = current_time - return - try: - current_time = time.time() - if len(self.upcoming_games) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - self.current_game_index = (self.current_game_index + 1) % len(self.upcoming_games) - self.current_game = self.upcoming_games[self.current_game_index] - self.last_game_switch = current_time - force_clear = True - - # Log team switching - if self.current_game: - away_team = self.current_game.get('away_team', 'UNK') - home_team = self.current_game.get('home_team', 'UNK') - logger.info(f"[NCAABASEBALL Upcoming] Showing {away_team} vs {home_team}") - - if self.current_game: - game_image = self._create_game_display(self.current_game) - self.display_manager.image = game_image - self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) - self.display_manager.update_display() - else: - logger.warning("[NCAABaseball] Current game is None, cannot display upcoming game.") - - except Exception as e: - logger.error(f"[NCAABaseball] Error displaying upcoming game: {e}", exc_info=True) \ No newline at end of file + self.logger = logging.getLogger('NCAABaseballUpcomingManager') # Changed logger name + self.logger.info(f"Initialized NCAABaseballUpcomingManager with {len(self.favorite_teams)} favorite teams") # Changed log prefix \ No newline at end of file diff --git a/src/ncaam_hockey_managers.py b/src/ncaam_hockey_managers.py index 2c6bd65a..0c240155 100644 --- a/src/ncaam_hockey_managers.py +++ b/src/ncaam_hockey_managers.py @@ -47,69 +47,116 @@ class BaseNCAAMHockeyManager(Hockey): # Renamed class self.logger.info(f"Initialized NCAAMHockey 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 _get_timezone(self): - try: - timezone_str = self.config.get('timezone', 'UTC') - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - 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_ncaa_fb_api_data(self, use_cache: bool = True) -> Optional[Dict]: + def _fetch_ncaa_hockey_api_data(self, use_cache: bool = True) -> Optional[Dict]: """ Fetches the full season schedule for NCAAMH, caches it, and then filters for relevant games based on the current configuration. """ now = datetime.now(pytz.utc) - current_year = now.year - years_to_check = [current_year] + season_year = now.year if now.month < 8: - years_to_check.append(current_year - 1) + season_year = now.year - 1 + datestring = f"{season_year}0901-{season_year+1}0501" + cache_key = f"ncaa_mens_hockey_schedule_{season_year}" - all_events = [] - for year in years_to_check: - cache_key = f"ncaamh_schedule_{year}" - if use_cache: - cached_data = self.cache_manager.get(cache_key) - if cached_data: - self.logger.info(f"[NCAAMH] Using cached schedule for {year}") - all_events.extend(cached_data) - continue - - self.logger.info(f"[NCAAMH] Fetching full {year} season schedule from ESPN API...") - try: - response = self.session.get(ESPN_NCAAMH_SCOREBOARD_URL, params={"dates": year,"limit":1000},headers=self.headers, timeout=15) - response.raise_for_status() - data = response.json() - events = data.get('events', []) - if use_cache: - self.cache_manager.set(cache_key, events) - self.logger.info(f"[NCAAMH] Successfully fetched and cached {len(events)} events for {year} season.") - all_events.extend(events) - except requests.exceptions.RequestException as e: - self.logger.error(f"[NCAAMH] API error fetching full schedule for {year}: {e}") - continue + 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) - if not all_events: - self.logger.warning("[NCAAMH] No events found in schedule data.") + # If background service is disabled, fall back to synchronous fetch + if not self.background_enabled or not self.background_service: + return self._fetch_ncaa_api_data_sync(use_cache) + + self.logger.info(f"Fetching full {season_year} season schedule from ESPN API...") + + # Start background fetch + 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="ncaa_mens_hockey", + year=season_year, + url=ESPN_NCAAMH_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 + return None + + + def _fetch_ncaa_api_data_sync(self, use_cache: bool = True) -> Optional[Dict]: + """ + Synchronous fallback for fetching NCAA Mens Hockey data when background service is disabled. + """ + now = datetime.now(pytz.utc) + current_year = now.year + cache_key = f"ncaa_mens_hockey_schedule_{current_year}" + + self.logger.info(f"Fetching full {current_year} season schedule from ESPN API (sync mode)...") + try: + response = self.session.get(ESPN_NCAAMH_SCOREBOARD_URL, params={"dates": current_year, "limit":1000}, headers=self.headers, timeout=15) + response.raise_for_status() + data = response.json() + events = data.get('events', []) + + if use_cache: + self.cache_manager.set(cache_key, events) + + self.logger.info(f"Successfully fetched {len(events)} events for the {current_year} season.") + return {'events': events} + except requests.exceptions.RequestException as e: + self.logger.error(f"[API error fetching full schedule: {e}") return None - - return {'events': all_events} - + def _fetch_data(self) -> Optional[Dict]: """Fetch data using shared data mechanism or direct fetch for live.""" if isinstance(self, NCAAMHockeyLiveManager): - return self._fetch_ncaa_fb_api_data(use_cache=False) + return self._fetch_todays_games() else: - return self._fetch_ncaa_fb_api_data(use_cache=True) + return self._fetch_ncaa_hockey_api_data(use_cache=True) class NCAAMHockeyLiveManager(BaseNCAAMHockeyManager, HockeyLive): # Renamed class """Manager for live NCAA Mens Hockey games.""" @@ -133,7 +180,8 @@ class NCAAMHockeyLiveManager(BaseNCAAMHockeyManager, HockeyLive): # Renamed clas "home_logo_path": Path(self.logo_dir, "RIT.png"), "away_logo_path": Path(self.logo_dir, "CLAR .png"), "game_time": "7:30 PM", - "game_date": "Apr 17" + "game_date": "Apr 17", + "is_live": True, "is_final": False, "is_upcoming": False, } self.live_games = [self.current_game] self.logger.info("Initialized NCAAMHockeyLiveManager with test game: RIT vs CLAR ") diff --git a/src/nfl_managers.py b/src/nfl_managers.py index 5557e57a..730e0669 100644 --- a/src/nfl_managers.py +++ b/src/nfl_managers.py @@ -1,14 +1,16 @@ -import os import logging -import requests -from typing import Dict, Any, Optional, List -from pathlib import Path +import os from datetime import datetime, timedelta -from src.display_manager import DisplayManager -from src.cache_manager import CacheManager +from pathlib import Path +from typing import Any, Dict, List, Optional + import pytz -from src.base_classes.sports import SportsRecent, SportsUpcoming +import requests + from src.base_classes.football import Football, FootballLive +from src.base_classes.sports import SportsRecent, SportsUpcoming +from src.cache_manager import CacheManager +from src.display_manager import DisplayManager # Constants ESPN_NFL_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard" @@ -50,7 +52,7 @@ class BaseNFLManager(Football): # Renamed class if now.month < 8: season_year = now.year - 1 datestring = f"{season_year}0801-{season_year+1}0301" - cache_key = f"nfl_schedule_{season_year}" + cache_key = f"{self.sport_key}_schedule_{season_year}" # Check cache first if use_cache: @@ -58,14 +60,14 @@ class BaseNFLManager(Football): # Renamed class if cached_data: # Validate cached data structure if isinstance(cached_data, dict) and 'events' in cached_data: - self.logger.info(f"[NFL] Using cached schedule for {season_year}") + 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"[NFL] Using cached schedule for {season_year} (legacy format)") + self.logger.info(f"Using cached schedule for {season_year} (legacy format)") return {'events': cached_data} else: - self.logger.warning(f"[NFL] Invalid cached data format for {season_year}: {type(cached_data)}") + self.logger.warning(f"Invalid cached data format for {season_year}: {type(cached_data)}") # Clear invalid cache self.cache_manager.clear_cache(cache_key) @@ -74,7 +76,7 @@ class BaseNFLManager(Football): # Renamed class return self._fetch_nfl_api_data_sync(use_cache) # Start background fetch - self.logger.info(f"[NFL] Starting background fetch for {season_year} season schedule...") + self.logger.info(f"Starting background fetch for {season_year} season schedule...") def fetch_callback(result): """Callback when background fetch completes.""" @@ -125,7 +127,7 @@ class BaseNFLManager(Football): # Renamed class current_year = now.year cache_key = f"nfl_schedule_{current_year}" - self.logger.info(f"[NFL] Fetching full {current_year} season schedule from ESPN API (sync mode)...") + self.logger.info(f"Fetching full {current_year} season schedule from ESPN API (sync mode)...") try: response = self.session.get(ESPN_NFL_SCOREBOARD_URL, params={"dates": current_year, "limit":1000}, headers=self.headers, timeout=15) response.raise_for_status() @@ -135,10 +137,10 @@ class BaseNFLManager(Football): # Renamed class if use_cache: self.cache_manager.set(cache_key, events) - self.logger.info(f"[NFL] Successfully fetched {len(events)} events for the {current_year} season.") + self.logger.info(f"Successfully fetched {len(events)} events for the {current_year} season.") return {'events': events} except requests.exceptions.RequestException as e: - self.logger.error(f"[NFL] API error fetching full schedule: {e}") + self.logger.error(f"API error fetching full schedule: {e}") return None def _fetch_data(self) -> Optional[Dict]: diff --git a/src/nhl_managers.py b/src/nhl_managers.py index 28c8b954..73033eaf 100644 --- a/src/nhl_managers.py +++ b/src/nhl_managers.py @@ -15,6 +15,8 @@ from src.background_data_service import get_background_service from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry import pytz +from src.base_classes.sports import SportsRecent, SportsUpcoming +from src.base_classes.hockey import Hockey, HockeyLive # Import the API counter function from web interface try: @@ -27,14 +29,7 @@ except ImportError: # Constants ESPN_NHL_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/scoreboard" -# Configure logging to match main configuration -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s.%(msecs)03d - %(levelname)s:%(name)s:%(message)s', - datefmt='%Y-%m-%d %H:%M:%S' -) - -class BaseNHLManager: +class BaseNHLManager(Hockey): """Base class for NHL managers with common functionality.""" # Class variables for warning tracking _no_data_warning_logged = False @@ -42,111 +37,35 @@ class BaseNHLManager: _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: DisplayManager, cache_manager: CacheManager): - self.display_manager = display_manager - # Store reference to config instead of creating new ConfigManager - self.config_manager = None # Not used in this class - self.config = config - self.cache_manager = cache_manager - self.odds_manager = OddsManager(self.cache_manager, None) - self.logger = logging.getLogger(__name__) - self.nhl_config = config.get("nhl_scoreboard", {}) - self.is_enabled = self.nhl_config.get("enabled", False) - self.show_odds = self.nhl_config.get("show_odds", False) - self.test_mode = self.nhl_config.get("test_mode", False) # Use test_mode from config - self.logo_dir = self.nhl_config.get("logo_dir", "assets/sports/nhl_logos") - self.update_interval = self.nhl_config.get("update_interval_seconds", 60) - self.show_records = self.nhl_config.get('show_records', False) - self.last_update = 0 - self.current_game = None - self.fonts = self._load_fonts() - self.favorite_teams = self.nhl_config.get("favorite_teams", []) - self.recent_hours = self.nhl_config.get("recent_game_hours", 48) # Default 48 hours - - # Set logging level to DEBUG to see all messages - self.logger.setLevel(logging.DEBUG) - - # Get display dimensions from matrix - self.display_width = self.display_manager.matrix.width - self.display_height = self.display_manager.matrix.height - - # Cache for loaded logos - self._logo_cache = {} - - # Set up HTTP session with retry strategy - self.session = requests.Session() - retry_strategy = Retry( - total=3, - backoff_factor=1, - status_forcelist=[429, 500, 502, 503, 504], - ) - adapter = HTTPAdapter(max_retries=retry_strategy) - self.session.mount("https://", adapter) - self.headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' - } - - # Initialize background data service - background_config = self.nhl_config.get("background_service", {}) - if background_config.get("enabled", True): # Default to enabled - max_workers = background_config.get("max_workers", 3) - self.background_service = get_background_service(self.cache_manager, max_workers) - self.background_fetch_requests = {} # Track background fetch requests - self.background_enabled = True - self.logger.info(f"[NHL] Background service enabled with {max_workers} workers") - else: - self.background_service = None - self.background_fetch_requests = {} - self.background_enabled = False - self.logger.info("[NHL] Background service disabled") - + self.logger = logging.getLogger('NHL') # Changed logger name + super().__init__(config=config, display_manager=display_manager, cache_manager=cache_manager, logger=self.logger, sport_key="nhl") + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("ncaam_hockey_recent", False) + self.upcoming_enabled = display_modes.get("ncaam_hockey_upcoming", False) + self.live_enabled = display_modes.get("ncaam_hockey_live", False) + self.league = "nhl" + self.logger.info(f"Initialized NHL manager with display dimensions: {self.display_width}x{self.display_height}") self.logger.info(f"Logo directory: {self.logo_dir}") - - def _fetch_odds(self, game: Dict) -> None: - """Fetch odds for a game and attach it to the game dictionary.""" - # Check if odds should be shown for this sport - if not self.show_odds: - return - - # Check if we should only fetch for favorite teams - is_favorites_only = self.nhl_config.get("show_favorite_teams_only", False) - if is_favorites_only: - home_abbr = game.get('home_abbr') - away_abbr = game.get('away_abbr') - if not (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams): - self.logger.debug(f"Skipping odds fetch for non-favorite game in favorites-only mode: {away_abbr}@{home_abbr}") - return - - self.logger.debug(f"Proceeding with odds fetch for game: {game.get('id', 'N/A')}") - - try: - odds_data = self.odds_manager.get_odds( - sport="hockey", - league="nhl", - event_id=game["id"] - ) - if odds_data: - game['odds'] = odds_data - except Exception as e: - self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}") - - def _get_timezone(self): - try: - timezone_str = self.config.get('timezone', 'UTC') - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - return pytz.utc - + self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}") + def _fetch_nhl_api_data(self, use_cache: bool = True) -> Optional[Dict]: """ Fetches NHL data using background threading. Returns cached data immediately if available, otherwise starts background fetch. """ now = datetime.now(pytz.utc) - current_year = now.year - cache_key = f"nhl_api_data_{current_year}" + season_year = now.year + if now.month < 8: + season_year = now.year - 1 + datestring = f"{season_year}0901-{season_year+1}0801" + cache_key = f"nhl_schedule_{season_year}" # Check cache first if use_cache: @@ -154,14 +73,14 @@ class BaseNHLManager: if cached_data: # Validate cached data structure if isinstance(cached_data, dict) and 'events' in cached_data: - self.logger.info(f"[NHL] Using cached data for {current_year}") + self.logger.info(f"[NHL] Using cached data for {season_year}") return cached_data elif isinstance(cached_data, list): # Handle old cache format (list of events) - self.logger.info(f"[NHL] Using cached data for {current_year} (legacy format)") + self.logger.info(f"[NHL] Using cached data for {season_year} (legacy format)") return {'events': cached_data} else: - self.logger.warning(f"[NHL] Invalid cached data format for {current_year}: {type(cached_data)}") + self.logger.warning(f"[NHL] Invalid cached data format for {season_year}: {type(cached_data)}") # Clear invalid cache self.cache_manager.clear_cache(cache_key) @@ -170,21 +89,21 @@ class BaseNHLManager: return self._fetch_nhl_api_data_sync(use_cache) # Start background fetch - self.logger.info(f"[NHL] Starting background fetch for {current_year} season schedule...") + self.logger.info(f"[NHL] 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 {current_year}: {len(result.data.get('events'))} events") + 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 {current_year}: {result.error}") + self.logger.error(f"Background fetch failed for {season_year}: {result.error}") # Clean up request tracking - if current_year in self.background_fetch_requests: - del self.background_fetch_requests[current_year] + if season_year in self.background_fetch_requests: + del self.background_fetch_requests[season_year] # Get background service configuration - background_config = self.nhl_config.get("background_service", {}) + 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) @@ -192,10 +111,10 @@ class BaseNHLManager: # Submit background fetch request request_id = self.background_service.submit_fetch_request( sport="nhl", - year=current_year, + year=season_year, url=ESPN_NHL_SCOREBOARD_URL, cache_key=cache_key, - params={"limit": 1000}, + params={"dates": datestring, "limit": 1000}, headers=self.headers, timeout=timeout, max_retries=max_retries, @@ -204,781 +123,68 @@ class BaseNHLManager: ) # Track the request - self.background_fetch_requests[current_year] = request_id + self.background_fetch_requests[season_year] = request_id # For immediate response, try to get partial data from cache - partial_data = self.cache_manager.get(cache_key) + partial_data = self._get_weeks_data() if partial_data: return partial_data return None - - def _fetch_nhl_api_data_sync(self, use_cache: bool = True) -> Optional[Dict]: - """ - Synchronous fallback for fetching NHL data when background service is disabled. - """ - now = datetime.now(pytz.utc) - current_year = now.year - cache_key = f"nhl_api_data_{current_year}" - - self.logger.info(f"[NHL] Fetching NHL data from ESPN API (sync mode)...") - try: - response = self.session.get(ESPN_NHL_SCOREBOARD_URL, params={"limit": 1000}, headers=self.headers, timeout=15) - response.raise_for_status() - data = response.json() - events = data.get('events', []) - - if use_cache: - self.cache_manager.set(cache_key, data) - - self.logger.info(f"[NHL] Successfully fetched {len(events)} events from ESPN API.") - return data - except requests.exceptions.RequestException as e: - self.logger.error(f"[NHL] API error fetching NHL data: {e}") - return None def _fetch_data(self, date_str: str = None) -> Optional[Dict]: """Fetch data using shared data mechanism or direct fetch for live.""" if isinstance(self, NHLLiveManager): # Live games should fetch only current games, not entire season - return self._fetch_nhl_api_data(use_cache=False) + return self._fetch_todays_games() else: # Recent and Upcoming managers should use cached season data return self._fetch_nhl_api_data(use_cache=True) - def _load_fonts(self): - """Load fonts used by the scoreboard.""" - fonts = {} - try: - fonts['score'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) - 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) - logging.info("[NHL] Successfully loaded Press Start 2P font for all text elements") - except IOError: - logging.warning("[NHL] Press Start 2P font not found, trying 4x6 font.") - try: - # Try to load the 4x6 font as a fallback - fonts['score'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 12) - fonts['time'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 8) - fonts['team'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 8) - fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 9) - logging.info("[NHL] Successfully loaded 4x6 font for all text elements") - except IOError: - logging.warning("[NHL] 4x6 font not found, using default PIL font.") - # Use default PIL font as a last resort - fonts['score'] = ImageFont.load_default() - fonts['time'] = ImageFont.load_default() - fonts['team'] = ImageFont.load_default() - fonts['status'] = ImageFont.load_default() - return fonts - - 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. - - Args: - draw: ImageDraw object - text: Text to draw - position: (x, y) position to draw the text - font: Font to use - fill: Text color (default: white) - outline_color: Outline color (default: black) - """ - x, y = position - - # Draw the outline by drawing the text in black at 8 positions around the text - 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 the text in the specified color - draw.text((x, y), text, font=font, fill=fill) - - def _load_and_resize_logo(self, team_abbrev: str) -> Optional[Image.Image]: - """Load and resize a team logo, with caching.""" - # self.logger.debug(f"Loading logo for {team_abbrev}") # Commented out - - if team_abbrev in self._logo_cache: - # self.logger.debug(f"Using cached logo for {team_abbrev}") # Commented out - return self._logo_cache[team_abbrev] - - logo_path = os.path.join(self.logo_dir, f"{team_abbrev}.png") - self.logger.debug(f"Logo path: {logo_path}") - - try: - # Create test logos if they don't exist - if not os.path.exists(logo_path): - self.logger.info(f"Creating test logo for {team_abbrev}") - os.makedirs(os.path.dirname(logo_path), exist_ok=True) - # Create a simple colored rectangle as a test logo - logo = Image.new('RGBA', (32, 32), (0, 0, 0, 0)) - draw = ImageDraw.Draw(logo) - # Use team abbreviation to determine color - if team_abbrev == "TB": - color = (0, 0, 255, 255) # Blue for Tampa Bay - else: - color = (255, 0, 0, 255) # Red for Dallas - draw.rectangle([4, 4, 28, 28], fill=color) - # Add team abbreviation - draw.text((8, 8), team_abbrev, fill=(255, 255, 255, 255)) - logo.save(logo_path) - self.logger.info(f"Created test logo at {logo_path}") - - logo = Image.open(logo_path) - self.logger.debug(f"Opened logo for {team_abbrev}, size: {logo.size}, mode: {logo.mode}") - - # Convert to RGBA if not already - if logo.mode != 'RGBA': - self.logger.debug(f"Converting {team_abbrev} logo from {logo.mode} to RGBA") - logo = logo.convert('RGBA') - - # Calculate max size based on display dimensions - # Make logos 150% of display width to allow them to extend off screen - max_width = int(self.display_width * 1.5) - max_height = int(self.display_height * 1.5) - - # Resize maintaining aspect ratio - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - self.logger.debug(f"Resized {team_abbrev} logo to {logo.size}") - - # Cache the resized logo - 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 _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN API response.""" - if not game_event: - return None - - try: - competition = game_event["competitions"][0] - status = competition["status"] - competitors = competition["competitors"] - game_date_str = game_event["date"] - - # Parse game date/time - try: - start_time_utc = datetime.fromisoformat(game_date_str.replace("Z", "+00:00")) - self.logger.debug(f"[NHL] Parsed game time: {start_time_utc}") - except ValueError: - logging.warning(f"[NHL] Could not parse game date: {game_date_str}") - start_time_utc = None - - home_team = next(c for c in competitors if c.get("homeAway") == "home") - away_team = next(c for c in competitors if c.get("homeAway") == "away") - 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 '' - - # Don't show "0-0" records - set to blank instead - if home_record == "0-0": - home_record = '' - if away_record == "0-0": - away_record = '' - - # Format game time and date for display - game_time = "" - game_date = "" - if start_time_utc: - # Convert to local time - local_time = start_time_utc.astimezone(self._get_timezone()) - game_time = local_time.strftime("%-I:%M%p") - - # 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: - game_date = self.display_manager.format_date_with_ordinal(local_time) - - details = { - "start_time_utc": start_time_utc, - "status_text": status["type"]["shortDetail"], - "period": status.get("period", 0), - "clock": status.get("displayClock", "0:00"), - "is_live": status["type"]["state"] in ("in", "halftime"), - "is_final": status["type"]["state"] == "post", - "is_upcoming": status["type"]["state"] == "pre", - "home_abbr": home_team["team"]["abbreviation"], - "home_score": home_team.get("score", "0"), - "home_record": home_record, - "home_logo_path": os.path.join(self.logo_dir, f"{home_team['team']['abbreviation']}.png"), - "away_abbr": away_team["team"]["abbreviation"], - "away_score": away_team.get("score", "0"), - "away_record": away_record, - "away_logo_path": os.path.join(self.logo_dir, f"{away_team['team']['abbreviation']}.png"), - "game_time": game_time, - "game_date": game_date, - "id": game_event.get("id") - } - - # Log game details for debugging - self.logger.debug(f"[NHL] Extracted game details: {details['away_abbr']} vs {details['home_abbr']}") - # Use .get() to avoid KeyError if optional keys are missing - self.logger.debug( - f"[NHL] Game status: is_final={details.get('is_final')}, " - f"is_upcoming={details.get('is_upcoming')}, is_live={details.get('is_live')}" - ) - - # Validate logo files - for team in ["home", "away"]: - logo_path = details[f"{team}_logo_path"] - if not os.path.isfile(logo_path): - logging.warning(f"[NHL] {team.title()} logo not found: {logo_path}") - details[f"{team}_logo_path"] = None - else: - try: - with Image.open(logo_path) as img: - logging.debug(f"[NHL] {team.title()} logo is valid: {img.format}, size: {img.size}") - except Exception as e: - logging.error(f"[NHL] {team.title()} logo file exists but is not valid: {e}") - details[f"{team}_logo_path"] = None - - return details - except Exception as e: - logging.error(f"[NHL] Error extracting game details: {e}") - return None - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the scorebug layout for the current game.""" - try: - # Create a new black image for the main display - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - - # Load logos once - home_logo = self._load_and_resize_logo(game["home_abbr"]) - away_logo = self._load_and_resize_logo(game["away_abbr"]) - - if not home_logo or not away_logo: - self.logger.error("Failed to load one or both team logos") - return - - # Create a single overlay for both logos - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - - # Calculate vertical center line for alignment - center_y = self.display_height // 2 - - # Draw home team logo (far right, extending beyond screen) - home_x = self.display_width - home_logo.width + 2 - home_y = center_y - (home_logo.height // 2) - - # Paste the home logo onto the overlay - overlay.paste(home_logo, (home_x, home_y), home_logo) - - # Draw away team logo (far left, extending beyond screen) - away_x = -2 - away_y = center_y - (away_logo.height // 2) - - # Paste the away logo onto the overlay - overlay.paste(away_logo, (away_x, away_y), away_logo) - - # Composite the overlay with the main image - main_img = Image.alpha_composite(main_img, overlay) - - # Convert to RGB for final display - main_img = main_img.convert('RGB') - draw = ImageDraw.Draw(main_img) - - # Check if this is an upcoming game - is_upcoming = game.get("is_upcoming", False) - - if is_upcoming: - # For upcoming games, show date and time stacked in the center - game_date = game.get("game_date", "") - game_time = game.get("game_time", "") - - # Show "Next Game" at the top - status_text = "Next Game" - status_width = draw.textlength(status_text, font=self.fonts['status']) - status_x = (self.display_width - status_width) // 2 - status_y = 2 - self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['status']) - - # Calculate position for the date text (centered horizontally, below "Next Game") - date_width = draw.textlength(game_date, font=self.fonts['time']) - date_x = (self.display_width - date_width) // 2 - date_y = center_y - 5 # Position in center - self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['time']) - - # Calculate position for the time text (centered horizontally, in center) - time_width = draw.textlength(game_time, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 - time_y = date_y + 10 # Position below date - self._draw_text_with_outline(draw, game_time, (time_x, time_y), self.fonts['time']) - else: - # For live/final games, show scores and period/time - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - - # Calculate position for the score text (centered at the bottom) - score_width = draw.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = self.display_height - 15 - self._draw_text_with_outline(draw, score_text, (score_x, score_y), self.fonts['score']) - - # Draw period and time or Final - if game.get("is_final", False): - status_text = "Final" - else: - period = game.get("period", 0) - clock = game.get("clock", "0:00") - - # Format period text - if period > 3: - period_text = "OT" - else: - period_text = f"{period}{'st' if period == 1 else 'nd' if period == 2 else 'rd'}" - - status_text = f"{period_text} {clock}" - - # Calculate position for the status text (centered at the top) - status_width = draw.textlength(status_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 5 - self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['time']) - - # Display odds if available - if 'odds' in game: - odds = game['odds'] - spread = odds.get('spread', {}).get('point', None) - if spread is not None: - # Format spread text - spread_text = f"{spread:+.1f}" if spread > 0 else f"{spread:.1f}" - - # Choose color and position based on which team has the spread - if odds.get('spread', {}).get('team') == game['home_abbr']: - text_color = (255, 100, 100) # Reddish - spread_x = self.display_width - draw.textlength(spread_text, font=self.fonts['status']) - 2 - else: - text_color = (100, 255, 100) # Greenish - spread_x = 2 - - spread_y = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), self.fonts['status'], fill=text_color) - - # Draw records if enabled - if self.show_records: - try: - record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - except IOError: - record_font = ImageFont.load_default() - - away_record = game.get('away_record', '') - home_record = game.get('home_record', '') - - 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: - away_record_x = 2 - self._draw_text_with_outline(draw, away_record, (away_record_x, record_y), record_font) - - if home_record: - home_record_bbox = draw.textbbox((0,0), home_record, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - 2 - self._draw_text_with_outline(draw, home_record, (home_record_x, record_y), record_font) - - # Display the image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() - - except Exception as e: - self.logger.error(f"Error displaying game: {e}", exc_info=True) - - def display(self, force_clear: bool = False) -> None: - """Common display method for all NHL managers""" - if not self.current_game: - current_time = time.time() - if not hasattr(self, '_last_warning_time'): - self._last_warning_time = 0 - if current_time - self._last_warning_time > 300: # 5 minutes cooldown - self.logger.warning("[NHL] No game data available to display") - self._last_warning_time = current_time - return - - self._draw_scorebug_layout(self.current_game, force_clear) - -class NHLLiveManager(BaseNHLManager): +class NHLLiveManager(BaseNHLManager, HockeyLive): """Manager for live NHL games.""" def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): super().__init__(config, display_manager, cache_manager) - self.update_interval = self.nhl_config.get("live_update_interval", 15) # 15 seconds for live games - self.no_data_interval = 300 # 5 minutes when no live games - self.last_update = 0 - self.logger.info("Initialized NHL Live Manager") - self.live_games = [] # List to store all live games - self.current_game_index = 0 # Index to track which game to show - self.last_game_switch = 0 # Track when we last switched games - self.game_display_duration = self.nhl_config.get("live_game_duration", 20) # Display each live game for 20 seconds - self.last_display_update = 0 # Track when we last updated the display - self.last_log_time = 0 - self.log_interval = 300 # Only log status every 5 minutes + self.logger = logging.getLogger('NHLLiveManager') # Changed logger name # Initialize with test game only if test mode is enabled if self.test_mode: self.current_game = { + "id": "401596361", "home_abbr": "TB", "away_abbr": "DAL", + "home_id": "178", + "away_id": "2137", "home_score": "3", "away_score": "2", "period": 2, "clock": "12:34", - "home_logo_path": os.path.join(self.logo_dir, "TB.png"), - "away_logo_path": os.path.join(self.logo_dir, "DAL.png"), + "home_logo_path": Path(self.logo_dir, "TB.png"), + "away_logo_path": Path(self.logo_dir, "DAL .png"), "game_time": "7:30 PM", - "game_date": "Apr 17" + "game_date": "Apr 17", + "is_live": True, "is_final": False, "is_upcoming": False, } self.live_games = [self.current_game] - logging.info("[NHL] Initialized NHLLiveManager with test game: TB vs DAL") + logging.info("Initialized NHLLiveManager with test game: TB vs DAL") else: - logging.info("[NHL] Initialized NHLLiveManager in live mode") + logging.info("Initialized NHLLiveManager in live mode") - def update(self): - """Update live game data.""" - if not self.is_enabled: return - current_time = time.time() - interval = self.no_data_interval if not self.live_games else self.update_interval - - if current_time - self.last_update >= interval: - self.last_update = current_time - - if self.test_mode: - # For testing, we'll just update the clock to show it's working - if self.current_game: - minutes = int(self.current_game["clock"].split(":")[0]) - seconds = int(self.current_game["clock"].split(":")[1]) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - minutes = 19 - if self.current_game["period"] < 3: - self.current_game["period"] += 1 - else: - self.current_game["period"] = 1 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Always update display in test mode - self.display(force_clear=True) - else: - # Fetch live game data from ESPN API - data = self._fetch_data() - if data and "events" in data: - # Find all live games involving favorite teams - new_live_games = [] - for event in data["events"]: - details = self._extract_game_details(event) - if details and details["is_live"]: - self._fetch_odds(details) - new_live_games.append(details) - - # Filter for favorite teams only if the config is set - if self.nhl_config.get("show_favorite_teams_only", False): - new_live_games = [game for game in new_live_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - - # Only log if there's a change in games or enough time has passed - should_log = ( - current_time - self.last_log_time >= self.log_interval or - len(new_live_games) != len(self.live_games) or - not self.live_games # Log if we had no games before - ) - - if should_log: - if new_live_games: - filter_text = "favorite teams" if self.nhl_config.get("show_favorite_teams_only", False) else "all teams" - self.logger.info(f"[NHL] Found {len(new_live_games)} live games involving {filter_text}") - for game in new_live_games: - self.logger.info(f"[NHL] Live game: {game['away_abbr']} vs {game['home_abbr']} - Period {game['period']}, {game['clock']}") - else: - filter_text = "favorite teams" if self.nhl_config.get("show_favorite_teams_only", False) else "criteria" - self.logger.info(f"[NHL] No live games found matching {filter_text}") - self.last_log_time = current_time - - if new_live_games: - # Update the current game with the latest data - for new_game in new_live_games: - if self.current_game and ( - (new_game["home_abbr"] == self.current_game["home_abbr"] and - new_game["away_abbr"] == self.current_game["away_abbr"]) or - (new_game["home_abbr"] == self.current_game["away_abbr"] and - new_game["away_abbr"] == self.current_game["home_abbr"]) - ): - self.current_game = new_game - break - - # Only update the games list if we have new games - if not self.live_games or set(game["away_abbr"] + game["home_abbr"] for game in new_live_games) != set(game["away_abbr"] + game["home_abbr"] for game in self.live_games): - self.live_games = new_live_games - # If we don't have a current game or it's not in the new list, start from the beginning - if not self.current_game or self.current_game not in self.live_games: - self.current_game_index = 0 - self.current_game = self.live_games[0] - self.last_game_switch = current_time - - # Update display if data changed, limit rate - if current_time - self.last_display_update >= 1.0: - # self.display(force_clear=True) # REMOVED: DisplayController handles this - self.last_display_update = current_time - - else: - # No live games found - self.live_games = [] - self.current_game = None - - # Check if it's time to switch games - if len(self.live_games) > 1 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 - - # Log team switching - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - self.logger.info(f"[NHL Live] Showing {away_abbr} vs {home_abbr}") - - # self.display(force_clear=True) # REMOVED: DisplayController handles this - self.last_display_update = current_time # Track time for potential display update - - def display(self, force_clear=False): - """Display live game information.""" - if not self.current_game: - return - super().display(force_clear) # Call parent class's display method - -class NHLRecentManager(BaseNHLManager): +class NHLRecentManager(BaseNHLManager, SportsRecent): """Manager for recently completed NHL games.""" def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): super().__init__(config, display_manager, cache_manager) - self.recent_games = [] - self.games_list = [] # Filtered list for display (favorite teams) - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.nhl_config.get("recent_update_interval", 3600) # Use config, default 1 hour - self.recent_games_to_show = self.nhl_config.get("recent_games_to_show", 5) # Number of most recent games to display - self.last_game_switch = 0 - self.game_display_duration = 15 # Display each game for 15 seconds + self.logger = logging.getLogger('NHLRecentManager') # Changed logger name self.logger.info(f"Initialized NHLRecentManager with {len(self.favorite_teams)} favorite teams") - - def update(self): - """Update recent games data.""" - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - - self.last_update = current_time - - try: - # Fetch data from ESPN API - data = self._fetch_data() - if not data or 'events' not in data: - self.logger.warning("[NHL] No events found in ESPN API response") - return - - events = data['events'] - self.logger.info(f"[NHL] Successfully fetched {len(events)} events from ESPN API") - - # Process games - processed_games = [] - for event in events: - game = self._extract_game_details(event) - if game and game['is_final']: - # Fetch odds if enabled - self._fetch_odds(game) - processed_games.append(game) - - # Filter for favorite teams only if the config is set - if self.nhl_config.get("show_favorite_teams_only", False): - # Get all games involving favorite teams - favorite_team_games = [game for game in processed_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - - # Select one game per favorite team (most recent game for each team) - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - team_specific_games = [game for game in favorite_team_games - if game['home_abbr'] == team or game['away_abbr'] == team] - - if team_specific_games: - # Sort by game time and take the most recent - team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - team_games.append(team_specific_games[0]) - - # Sort the final list by game time (most recent first) - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - else: - team_games = processed_games - # Sort games by start time, most recent first, then limit to recent_games_to_show - team_games.sort(key=lambda x: x.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - team_games = team_games[:self.recent_games_to_show] - self.logger.info(f"[NHL] Found {len(team_games)} recent games for favorite teams (limited to {self.recent_games_to_show})") - - new_game_ids = {g['id'] for g in team_games} - current_game_ids = {g['id'] for g in getattr(self, 'games_list', [])} - if new_game_ids != current_game_ids: - self.games_list = team_games - self.current_game_index = 0 - self.current_game = self.games_list[0] if self.games_list else None - self.last_game_switch = current_time - elif self.games_list: - self.current_game = self.games_list[self.current_game_index] - - if not self.games_list: - self.current_game = None - - except Exception as e: - self.logger.error(f"[NHL] Error updating recent games: {e}", exc_info=True) - - def display(self, force_clear=False): - """Display recent games.""" - if not self.games_list: - self.logger.info("[NHL] No recent games to display") - return # Skip display update entirely - - try: - current_time = time.time() - - # Check if it's time to switch games - if len(self.games_list) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - # Move to next game - 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 clear when switching games - - # Log team switching - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - self.logger.info(f"[NHL Recent] Showing {away_abbr} vs {home_abbr}") - - # Draw the scorebug layout - self._draw_scorebug_layout(self.current_game, force_clear) - - # Update display - self.display_manager.update_display() - - except Exception as e: - self.logger.error(f"[NHL] Error displaying recent game: {e}", exc_info=True) - -class NHLUpcomingManager(BaseNHLManager): +class NHLUpcomingManager(BaseNHLManager, SportsUpcoming): """Manager for upcoming NHL games.""" def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): super().__init__(config, display_manager, cache_manager) - self.upcoming_games = [] - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.nhl_config.get("recent_update_interval", 3600) # Use config, default 1 hour - self.upcoming_games_to_show = self.nhl_config.get("upcoming_games_to_show", 5) # Number of upcoming games to display - self.last_log_time = 0 - self.log_interval = 300 # Only log status every 5 minutes - self.last_warning_time = 0 - self.warning_cooldown = 300 # Only show warning every 5 minutes - self.last_game_switch = 0 # Track when we last switched games - self.game_display_duration = 15 # Display each game for 15 seconds + self.logger = logging.getLogger('NHLUpcomingManager') # Changed logger name self.logger.info(f"Initialized NHLUpcomingManager with {len(self.favorite_teams)} favorite teams") - def update(self): - """Update upcoming games data.""" - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - - self.last_update = current_time - - try: - # Fetch data from ESPN API - data = self._fetch_data() - if not data or 'events' not in data: - self.logger.warning("[NHL] No events found in ESPN API response") - return - - events = data['events'] - self.logger.info(f"[NHL] Successfully fetched {len(events)} events from ESPN API") - - # Process games - new_upcoming_games = [] - for event in events: - game = self._extract_game_details(event) - if game and game['is_upcoming']: - # Only fetch odds for games that will be displayed - if self.nhl_config.get("show_favorite_teams_only", False): - if not self.favorite_teams or (game['home_abbr'] not in self.favorite_teams and game['away_abbr'] not in self.favorite_teams): - continue - - self._fetch_odds(game) - new_upcoming_games.append(game) - - # Filter for favorite teams only if the config is set - if self.nhl_config.get("show_favorite_teams_only", False): - # Get all games involving favorite teams - favorite_team_games = [game for game in new_upcoming_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - - # Select one game per favorite team (earliest upcoming game for each team) - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - team_specific_games = [game for game in favorite_team_games - if game['home_abbr'] == team or game['away_abbr'] == team] - - if team_specific_games: - # Sort by game time and take the earliest - team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - team_games.append(team_specific_games[0]) - - # Sort the final list by game time - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - else: - team_games = new_upcoming_games - # Sort games by start time, soonest first, then limit to configured count - team_games.sort(key=lambda x: x.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - team_games = team_games[:self.upcoming_games_to_show] - - # Only log if there's a change in games or enough time has passed - should_log = ( - current_time - self.last_log_time >= self.log_interval or - len(team_games) != len(self.upcoming_games) or - not self.upcoming_games # Log if we had no games before - ) - - if should_log: - if team_games: - self.logger.info(f"[NHL] Found {len(team_games)} upcoming games for favorite teams (limited to {self.upcoming_games_to_show})") - for game in team_games: - self.logger.info(f"[NHL] Upcoming game: {game['away_abbr']} vs {game['home_abbr']} - {game['game_date']} {game['game_time']}") - else: - self.logger.info("[NHL] No upcoming games found for favorite teams") - self.logger.debug(f"[NHL] Favorite teams: {self.favorite_teams}") - self.last_log_time = current_time - - self.upcoming_games = team_games - if self.upcoming_games: - if not self.current_game or self.current_game['id'] not in {g['id'] for g in self.upcoming_games}: - self.current_game_index = 0 - self.current_game = self.upcoming_games[0] - self.last_game_switch = current_time - else: - self.current_game = None - - except Exception as e: - self.logger.error(f"[NHL] Error updating upcoming games: {e}", exc_info=True) - - def display(self, force_clear=False): """Display upcoming games.""" if not self.upcoming_games: current_time = time.time()