diff --git a/assets/sports/wnba_logos/.keep b/assets/sports/wnba_logos/.keep new file mode 100644 index 00000000..e69de29b diff --git a/assets/sports/wnba_logos/LV.png b/assets/sports/wnba_logos/LV.png new file mode 100644 index 00000000..888e5ada Binary files /dev/null and b/assets/sports/wnba_logos/LV.png differ diff --git a/assets/sports/wnba_logos/PHX.png b/assets/sports/wnba_logos/PHX.png new file mode 100644 index 00000000..b0fa0720 Binary files /dev/null and b/assets/sports/wnba_logos/PHX.png differ diff --git a/config/config.template.json b/config/config.template.json index 9696e944..18fccb47 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -267,6 +267,7 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, + "show_all_live": false, "favorite_teams": [ "DAL" ], @@ -285,6 +286,40 @@ "nba_upcoming": true } }, + "wnba_scoreboard": { + "enabled": false, + "live_priority": true, + "live_game_duration": 20, + "show_odds": true, + "test_mode": false, + "update_interval_seconds": 3600, + "live_update_interval": 30, + "live_odds_update_interval": 3600, + "odds_update_interval": 3600, + "recent_update_interval": 3600, + "upcoming_update_interval": 3600, + "recent_games_to_show": 1, + "upcoming_games_to_show": 1, + "show_favorite_teams_only": true, + "show_all_live": false, + "favorite_teams": [ + "CHI" + ], + "logo_dir": "assets/sports/wnba_logos", + "show_records": true, + "background_service": { + "enabled": true, + "max_workers": 3, + "request_timeout": 30, + "max_retries": 3, + "priority": 2 + }, + "display_modes": { + "wnba_live": true, + "wnba_recent": true, + "wnba_upcoming": true + } + }, "nfl_scoreboard": { "enabled": false, "live_priority": true, @@ -388,6 +423,7 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, + "show_all_live": false, "favorite_teams": [ "UGA", "AUB" @@ -400,6 +436,30 @@ "ncaam_basketball_upcoming": true } }, + "ncaaw_basketball_scoreboard": { + "enabled": false, + "live_priority": true, + "live_game_duration": 20, + "show_odds": true, + "test_mode": false, + "update_interval_seconds": 3600, + "live_update_interval": 30, + "recent_games_to_show": 1, + "upcoming_games_to_show": 1, + "show_favorite_teams_only": true, + "show_all_live": false, + "favorite_teams": [ + "UGA", + "AUB" + ], + "logo_dir": "assets/sports/ncaa_logos", + "show_records": true, + "display_modes": { + "ncaaw_basketball_live": true, + "ncaaw_basketball_recent": true, + "ncaaw_basketball_upcoming": true + } + }, "ncaam_hockey_scoreboard": { "enabled": false, "live_priority": true, diff --git a/src/base_classes/basketball.py b/src/base_classes/basketball.py new file mode 100644 index 00000000..e4647703 --- /dev/null +++ b/src/base_classes/basketball.py @@ -0,0 +1,309 @@ +import logging +import time +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 Basketball(SportsCore): + """Base class for basketball 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) + self.data_source = ESPNDataSource(logger) + self.sport = "basketball" + + 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) + ) + if details is None or home_team is None or away_team is None or status is None: + return + try: + # 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 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": + if period > 4 : period_text = "Final/OT" + else: period_text = "Final" + elif status["type"]["state"] == "pre": + 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"), + }) + + # Basic validation (can be expanded) + if not details["home_abbr"] or not details["away_abbr"]: + self.logger.warning( + f"Missing team abbreviation in event: {details['id']}" + ) + return None + + self.logger.debug( + f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}" + ) + + return details + except Exception as e: + # Log the problematic event structure if possible + self.logger.error( + f"Error extracting game details: {e} from event: {game_event.get('id')}", + exc_info=True, + ) + return None + + +class BasketballLive(Basketball, 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) + + 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: + 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 Basketball 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) + + # --- 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() + ) + + 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_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"] + ) + + # 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 + ) + + # Draw records or rankings if enabled + if self.show_records or self.show_ranking: + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + self.logger.debug(f"Loaded 6px record font successfully") + except IOError: + record_font = ImageFont.load_default() + self.logger.warning( + f"Failed to load 6px font, using default font (size: {record_font.size})" + ) + + # Get team abbreviations + away_abbr = game.get("away_abbr", "") + home_abbr = game.get("home_abbr", "") + + record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height - 1 + self.logger.debug( + f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" + ) + + # Display away team info + if away_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + away_text = "" + elif self.show_ranking: + # Show ranking only if available + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + away_text = "" + elif self.show_records: + # Show record only when rankings are disabled + away_text = game.get("away_record", "") + else: + away_text = "" + + if away_text: + away_record_x = 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, + ) + + # Display home team info + if home_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + home_text = "" + elif self.show_ranking: + # Show ranking only if available + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + home_text = "" + elif self.show_records: + # Show record only when rankings are disabled + home_text = game.get("home_record", "") + else: + home_text = "" + + if home_text: + home_record_bbox = draw_overlay.textbbox( + (0, 0), home_text, font=record_font + ) + home_record_width = home_record_bbox[2] - home_record_bbox[0] + home_record_x = self.display_width - home_record_width - 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, + ) + + # 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 Hockey game: {e}", exc_info=True + ) # Changed log prefix diff --git a/src/base_classes/sports.py b/src/base_classes/sports.py index 3c0d0585..24e502e8 100644 --- a/src/base_classes/sports.py +++ b/src/base_classes/sports.py @@ -419,6 +419,7 @@ class SportsCore(ABC): if not home_team or not away_team: self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}") return None, None, None, None, None + try: home_abbr = home_team["team"]["abbreviation"] except KeyError: diff --git a/src/display_controller.py b/src/display_controller.py index b7777a88..04abe282 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -23,6 +23,7 @@ from src.odds_ticker_manager import OddsTickerManager from src.leaderboard_manager import LeaderboardManager from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager +from src.wnba_managers import WNBALiveManager, WNBARecentManager, WNBAUpcomingManager from src.mlb_manager import MLBLiveManager, MLBRecentManager, MLBUpcomingManager from src.milb_manager import MiLBLiveManager, MiLBRecentManager, MiLBUpcomingManager from src.soccer_managers import SoccerLiveManager, SoccerRecentManager, SoccerUpcomingManager @@ -30,6 +31,7 @@ from src.nfl_managers import NFLLiveManager, NFLRecentManager, NFLUpcomingManage from src.ncaa_fb_managers import NCAAFBLiveManager, NCAAFBRecentManager, NCAAFBUpcomingManager from src.ncaa_baseball_managers import NCAABaseballLiveManager, NCAABaseballRecentManager, NCAABaseballUpcomingManager from src.ncaam_basketball_managers import NCAAMBasketballLiveManager, NCAAMBasketballRecentManager, NCAAMBasketballUpcomingManager +from src.ncaaw_basketball_managers import NCAAWBasketballLiveManager, NCAAWBasketballRecentManager, NCAAWBasketballUpcomingManager from src.ncaam_hockey_managers import NCAAMHockeyLiveManager, NCAAMHockeyRecentManager, NCAAMHockeyUpcomingManager from src.ncaaw_hockey_managers import NCAAWHockeyLiveManager, NCAAWHockeyRecentManager, NCAAWHockeyUpcomingManager from src.youtube_display import YouTubeDisplay @@ -134,6 +136,21 @@ class DisplayController: self.nba_recent = None self.nba_upcoming = None logger.info("NBA managers initialized in %.3f seconds", time.time() - nba_time) + + # Initialize WNBA managers if enabled + wnba_time = time.time() + wnba_enabled = self.config.get('wnba_scoreboard', {}).get('enabled', False) + wnba_display_modes = self.config.get('wnba_scoreboard', {}).get('display_modes', {}) + + if wnba_enabled: + self.wnba_live = WNBALiveManager(self.config, self.display_manager, self.cache_manager) if wnba_display_modes.get('wnba_live', True) else None + self.wnba_recent = WNBARecentManager(self.config, self.display_manager, self.cache_manager) if wnba_display_modes.get('wnba_recent', True) else None + self.wnba_upcoming = WNBAUpcomingManager(self.config, self.display_manager, self.cache_manager) if wnba_display_modes.get('wnba_upcoming', True) else None + else: + self.wnba_live = None + self.wnba_recent = None + self.wnba_upcoming = None + logger.info("WNBA managers initialized in %.3f seconds", time.time() - wnba_time) # Initialize MLB managers if enabled mlb_time = time.time() @@ -242,6 +259,21 @@ class DisplayController: self.ncaam_basketball_upcoming = None logger.info("NCAA Men's Basketball managers initialized in %.3f seconds", time.time() - ncaam_basketball_time) + # Initialize NCAA Womens's Basketball managers if enabled + ncaaw_basketball_time = time.time() + ncaaw_basketball_enabled = self.config.get('ncaaw_basketball_scoreboard', {}).get('enabled', False) + ncaaw_basketball_display_modes = self.config.get('ncaaw_basketball_scoreboard', {}).get('display_modes', {}) + + if ncaaw_basketball_enabled: + self.ncaaw_basketball_live = NCAAWBasketballLiveManager(self.config, self.display_manager, self.cache_manager) if ncaaw_basketball_display_modes.get('ncaaw_basketball_live', True) else None + self.ncaaw_basketball_recent = NCAAWBasketballRecentManager(self.config, self.display_manager, self.cache_manager) if ncaaw_basketball_display_modes.get('ncaaw_basketball_recent', True) else None + self.ncaaw_basketball_upcoming = NCAAWBasketballUpcomingManager(self.config, self.display_manager, self.cache_manager) if ncaaw_basketball_display_modes.get('ncaaw_basketball_upcoming', True) else None + else: + self.ncaaw_basketball_live = None + self.ncaaw_basketball_recent = None + self.ncaaw_basketball_upcoming = None + logger.info("NCAA Womens's Basketball managers initialized in %.3f seconds", time.time() - ncaaw_basketball_time) + # Initialize NCAA Men's Hockey managers if enabled ncaam_hockey_time = time.time() ncaam_hockey_enabled = self.config.get('ncaam_hockey_scoreboard', {}).get('enabled', False) @@ -281,6 +313,7 @@ class DisplayController: # Read live_priority flags for all sports self.nhl_live_priority = self.config.get('nhl_scoreboard', {}).get('live_priority', True) self.nba_live_priority = self.config.get('nba_scoreboard', {}).get('live_priority', True) + self.wnba_live_priority = self.config.get('wnba_scoreboard', {}).get('live_priority', True) self.mlb_live_priority = self.config.get('mlb_scoreboard', {}).get('live_priority', True) self.milb_live_priority = self.config.get('milb_scoreboard', {}).get('live_priority', True) self.soccer_live_priority = self.config.get('soccer_scoreboard', {}).get('live_priority', True) @@ -288,6 +321,7 @@ class DisplayController: self.ncaa_fb_live_priority = self.config.get('ncaa_fb_scoreboard', {}).get('live_priority', True) self.ncaa_baseball_live_priority = self.config.get('ncaa_baseball_scoreboard', {}).get('live_priority', True) self.ncaam_basketball_live_priority = self.config.get('ncaam_basketball_scoreboard', {}).get('live_priority', True) + self.ncaaw_basketball_live_priority = self.config.get('ncaaw_basketball_scoreboard', {}).get('live_priority', True) self.ncaam_hockey_live_priority = self.config.get('ncaam_hockey_scoreboard', {}).get('live_priority', True) self.ncaaw_hockey_live_priority = self.config.get('ncaaw_hockey_scoreboard', {}).get('live_priority', True) @@ -315,6 +349,9 @@ class DisplayController: if nba_enabled: if self.nba_recent: self.available_modes.append('nba_recent') if self.nba_upcoming: self.available_modes.append('nba_upcoming') + if wnba_enabled: + if self.wnba_recent: self.available_modes.append('wnba_recent') + if self.wnba_upcoming: self.available_modes.append('wnba_upcoming') if mlb_enabled: if self.mlb_recent: self.available_modes.append('mlb_recent') if self.mlb_upcoming: self.available_modes.append('mlb_upcoming') @@ -336,6 +373,9 @@ class DisplayController: if ncaam_basketball_enabled: if self.ncaam_basketball_recent: self.available_modes.append('ncaam_basketball_recent') if self.ncaam_basketball_upcoming: self.available_modes.append('ncaam_basketball_upcoming') + if ncaaw_basketball_enabled: + if self.ncaaw_basketball_recent: self.available_modes.append('ncaaw_basketball_recent') + if self.ncaaw_basketball_upcoming: self.available_modes.append('ncaaw_basketball_upcoming') if ncaam_hockey_enabled: if self.ncaam_hockey_recent: self.available_modes.append('ncaam_hockey_recent') if self.ncaam_hockey_upcoming: self.available_modes.append('ncaam_hockey_upcoming') @@ -366,6 +406,11 @@ class DisplayController: self.nba_favorite_teams = self.config.get('nba_scoreboard', {}).get('favorite_teams', []) self.in_nba_rotation = False + self.wnba_current_team_index = 0 + self.wnba_showing_recent = True + self.wnba_favorite_teams = self.config.get('wnba_scoreboard', {}).get('favorite_teams', []) + self.in_wnba_rotation = False + self.soccer_current_team_index = 0 # Soccer rotation state self.soccer_showing_recent = True self.soccer_favorite_teams = self.config.get('soccer_scoreboard', {}).get('favorite_teams', []) @@ -394,6 +439,12 @@ class DisplayController: self.ncaam_basketball_showing_recent = True self.ncaam_basketball_favorite_teams = self.config.get('ncaam_basketball_scoreboard', {}).get('favorite_teams', []) self.in_ncaam_basketball_rotation = False + + # Add NCAA Womens's Basketball rotation state + self.ncaaw_basketball_current_team_index = 0 + self.ncaaw_basketball_showing_recent = True + self.ncaaw_basketball_favorite_teams = self.config.get('ncaaw_basketball_scoreboard', {}).get('favorite_teams', []) + self.in_ncaaw_basketball_rotation = False # Update display durations to include all modes self.display_durations = self.config['display'].get('display_durations', {}) @@ -423,6 +474,9 @@ class DisplayController: 'nba_live': 30, 'nba_recent': 20, 'nba_upcoming': 20, + 'wnba_live': 30, + 'wnba_recent': 20, + 'wnba_upcoming': 20, 'mlb_live': 30, 'mlb_recent': 20, 'mlb_upcoming': 20, @@ -445,6 +499,9 @@ class DisplayController: 'ncaam_basketball_live': 30, # Added NCAA Men's Basketball durations 'ncaam_basketball_recent': 15, 'ncaam_basketball_upcoming': 15, + 'ncaaw_basketball_live': 30, # Added NCAA Womens's Basketball durations + 'ncaaw_basketball_recent': 15, + 'ncaaw_basketball_upcoming': 15, 'ncaam_hockey_live': 30, # Added NCAA Men's Hockey durations 'ncaam_hockey_recent': 15, 'ncaam_hockey_upcoming': 15, @@ -462,6 +519,8 @@ class DisplayController: logger.info(f"NHL Favorite teams: {self.nhl_favorite_teams}") if nba_enabled: logger.info(f"NBA Favorite teams: {self.nba_favorite_teams}") + if wnba_enabled: + logger.info(f"WNBA Favorite teams: {self.wnba_favorite_teams}") if mlb_enabled: logger.info(f"MLB Favorite teams: {self.mlb_favorite_teams}") if milb_enabled: @@ -476,6 +535,8 @@ class DisplayController: logger.info(f"NCAA Baseball Favorite teams: {self.ncaa_baseball_favorite_teams}") if ncaam_basketball_enabled: # Check if NCAA Men's Basketball is enabled logger.info(f"NCAA Men's Basketball Favorite teams: {self.ncaam_basketball_favorite_teams}") + if ncaaw_basketball_enabled: # Check if NCAA Womens's Basketball is enabled + logger.info(f"NCAA Womens's Basketball Favorite teams: {self.ncaaw_basketball_favorite_teams}") logger.info(f"Available display modes: {self.available_modes}") logger.info(f"Initial display mode: {self.current_display_mode}") @@ -674,6 +735,10 @@ class DisplayController: if self.nba_live: self.nba_live.update() if self.nba_recent: self.nba_recent.update() if self.nba_upcoming: self.nba_upcoming.update() + elif current_sport == 'wnba': + if self.wnba_live: self.wnba_live.update() + if self.wnba_recent: self.wnba_recent.update() + if self.wnba_upcoming: self.wnba_upcoming.update() elif current_sport == 'mlb': if self.mlb_live: self.mlb_live.update() if self.mlb_recent: self.mlb_recent.update() @@ -702,6 +767,10 @@ class DisplayController: if self.ncaam_basketball_live: self.ncaam_basketball_live.update() if self.ncaam_basketball_recent: self.ncaam_basketball_recent.update() if self.ncaam_basketball_upcoming: self.ncaam_basketball_upcoming.update() + elif current_sport == 'ncaaw_basketball': + if self.ncaaw_basketball_live: self.ncaaw_basketball_live.update() + if self.ncaaw_basketball_recent: self.ncaaw_basketball_recent.update() + if self.ncaaw_basketball_upcoming: self.ncaaw_basketball_upcoming.update() elif current_sport == 'ncaam_hockey': if self.ncaam_hockey_live: self.ncaam_hockey_live.update() if self.ncaam_hockey_recent: self.ncaam_hockey_recent.update() @@ -721,6 +790,10 @@ class DisplayController: if self.nba_recent: self.nba_recent.update() if self.nba_upcoming: self.nba_upcoming.update() + if self.wnba_live: self.wnba_live.update() + if self.wnba_recent: self.wnba_recent.update() + if self.wnba_upcoming: self.wnba_upcoming.update() + if self.mlb_live: self.mlb_live.update() if self.mlb_recent: self.mlb_recent.update() if self.mlb_upcoming: self.mlb_upcoming.update() @@ -749,6 +822,10 @@ class DisplayController: if self.ncaam_basketball_recent: self.ncaam_basketball_recent.update() if self.ncaam_basketball_upcoming: self.ncaam_basketball_upcoming.update() + if self.ncaaw_basketball_live: self.ncaaw_basketball_live.update() + if self.ncaaw_basketball_recent: self.ncaaw_basketball_recent.update() + if self.ncaaw_basketball_upcoming: self.ncaaw_basketball_upcoming.update() + if self.ncaam_hockey_live: self.ncaam_hockey_live.update() if self.ncaam_hockey_recent: self.ncaam_hockey_recent.update() if self.ncaam_hockey_upcoming: self.ncaam_hockey_upcoming.update() @@ -770,6 +847,8 @@ class DisplayController: live_checks['nhl'] = self.nhl_live and self.nhl_live.live_games if 'nba_scoreboard' in self.config and self.config['nba_scoreboard'].get('enabled', False): live_checks['nba'] = self.nba_live and self.nba_live.live_games + if 'wnba_scoreboard' in self.config and self.config['wnba_scoreboard'].get('enabled', False): + live_checks['wnba'] = self.wnba_live and self.wnba_live.live_games if 'mlb' in self.config and self.config['mlb'].get('enabled', False): live_checks['mlb'] = self.mlb_live and self.mlb_live.live_games if 'milb' in self.config and self.config['milb'].get('enabled', False): @@ -784,6 +863,8 @@ class DisplayController: live_checks['ncaa_baseball'] = self.ncaa_baseball_live and self.ncaa_baseball_live.live_games if 'ncaam_basketball_scoreboard' in self.config and self.config['ncaam_basketball_scoreboard'].get('enabled', False): live_checks['ncaam_basketball'] = self.ncaam_basketball_live and self.ncaam_basketball_live.live_games + if 'ncaaw_basketball_scoreboard' in self.config and self.config['ncaaw_basketball_scoreboard'].get('enabled', False): + live_checks['ncaaw_basketball'] = self.ncaaw_basketball_live and self.ncaaw_basketball_live.live_games if 'ncaam_hockey_scoreboard' in self.config and self.config['ncaam_hockey_scoreboard'].get('enabled', False): live_checks['ncaam_hockey'] = self.ncaam_hockey_live and self.ncaam_hockey_live.live_games if 'ncaaw_hockey_scoreboard' in self.config and self.config['ncaaw_hockey_scoreboard'].get('enabled', False): @@ -818,6 +899,9 @@ class DisplayController: elif sport == 'nba': manager_recent = self.nba_recent manager_upcoming = self.nba_upcoming + elif sport == 'wnba': + manager_recent = self.wnba_recent + manager_upcoming = self.wnba_upcoming elif sport == 'mlb': manager_recent = self.mlb_recent manager_upcoming = self.mlb_upcoming @@ -872,6 +956,10 @@ class DisplayController: favorite_teams = self.nba_favorite_teams manager_recent = self.nba_recent manager_upcoming = self.nba_upcoming + elif sport == 'wnba': + favorite_teams = self.wnba_favorite_teams + manager_recent = self.wnba_recent + manager_upcoming = self.wnba_upcoming elif sport == 'mlb': favorite_teams = self.mlb_favorite_teams manager_recent = self.mlb_recent @@ -895,73 +983,6 @@ class DisplayController: return bool(favorite_teams and (manager_recent or manager_upcoming)) - def _rotate_team_games(self, sport: str = 'nhl') -> None: - """Rotate through games for favorite teams. (No longer used directly in loop)""" - # This logic is now mostly handled within each manager's display/update - # Keeping the structure in case direct rotation is needed later. - if not self._has_team_games(sport): - return - - if sport == 'nhl': - if not self.nhl_favorite_teams: return - current_team = self.nhl_favorite_teams[self.nhl_current_team_index] - # ... (rest of NHL rotation logic - now less relevant) - elif sport == 'nba': - if not self.nba_favorite_teams: return - current_team = self.nba_favorite_teams[self.nba_current_team_index] - # ... (rest of NBA rotation logic) - elif sport == 'mlb': - if not self.mlb_favorite_teams: return - current_team = self.mlb_favorite_teams[self.mlb_current_team_index] - # ... (rest of MLB rotation logic) - elif sport == 'milb': - if not self.config.get('milb_scoreboard', {}).get('favorite_teams', []): return - current_team = self.config['milb_scoreboard']['favorite_teams'][self.milb_current_team_index] - # ... (rest of MiLB rotation logic) - elif sport == 'soccer': - if not self.soccer_favorite_teams: return - current_team = self.soccer_favorite_teams[self.soccer_current_team_index] - # Try to find games for current team (recent first) - found_games = self._get_team_games(current_team, 'soccer', self.soccer_showing_recent) - if not found_games: - # Try opposite type (upcoming/recent) - self.soccer_showing_recent = not self.soccer_showing_recent - found_games = self._get_team_games(current_team, 'soccer', self.soccer_showing_recent) - - if not found_games: - # Move to next team if no games found for current one - self.soccer_current_team_index = (self.soccer_current_team_index + 1) % len(self.soccer_favorite_teams) - self.soccer_showing_recent = True # Reset to recent for the new team - # Maybe try finding game for the *new* team immediately? Optional. - elif sport == 'nfl': - if not self.nfl_favorite_teams: return - current_team = self.nfl_favorite_teams[self.nfl_current_team_index] - # Try to find games for current team (recent first) - found_games = self._get_team_games(current_team, 'nfl', self.nfl_showing_recent) - if not found_games: - # Try opposite type (upcoming/recent) - self.nfl_showing_recent = not self.nfl_showing_recent - found_games = self._get_team_games(current_team, 'nfl', self.nfl_showing_recent) - - if not found_games: - # Move to next team if no games found for current one - self.nfl_current_team_index = (self.nfl_current_team_index + 1) % len(self.nfl_favorite_teams) - self.nfl_showing_recent = True # Reset to recent for the new team - elif sport == 'ncaa_fb': # Add NCAA FB case - if not self.ncaa_fb_favorite_teams: return - current_team = self.ncaa_fb_favorite_teams[self.ncaa_fb_current_team_index] - # Try to find games for current team (recent first) - found_games = self._get_team_games(current_team, 'ncaa_fb', self.ncaa_fb_showing_recent) - if not found_games: - # Try opposite type (upcoming/recent) - self.ncaa_fb_showing_recent = not self.ncaa_fb_showing_recent - found_games = self._get_team_games(current_team, 'ncaa_fb', self.ncaa_fb_showing_recent) - - if not found_games: - # Move to next team if no games found for current one - self.ncaa_fb_current_team_index = (self.ncaa_fb_current_team_index + 1) % len(self.ncaa_fb_favorite_teams) - self.ncaa_fb_showing_recent = True # Reset to recent for the new team - # --- SCHEDULING METHODS --- def _load_schedule_config(self): """Load schedule configuration once at startup.""" @@ -1031,6 +1052,7 @@ class DisplayController: # Check if each sport is enabled before processing nhl_enabled = self.config.get('nhl_scoreboard', {}).get('enabled', False) nba_enabled = self.config.get('nba_scoreboard', {}).get('enabled', False) + wnba_enabled = self.config.get('wnba_scoreboard', {}).get('enabled', False) mlb_enabled = self.config.get('mlb_scoreboard', {}).get('enabled', False) milb_enabled = self.config.get('milb_scoreboard', {}).get('enabled', False) soccer_enabled = self.config.get('soccer_scoreboard', {}).get('enabled', False) @@ -1038,11 +1060,13 @@ class DisplayController: ncaa_fb_enabled = self.config.get('ncaa_fb_scoreboard', {}).get('enabled', False) ncaa_baseball_enabled = self.config.get('ncaa_baseball_scoreboard', {}).get('enabled', False) ncaam_basketball_enabled = self.config.get('ncaam_basketball_scoreboard', {}).get('enabled', False) + ncaaw_basketball_enabled = self.config.get('ncaaw_basketball_scoreboard', {}).get('enabled', False) ncaam_hockey_enabled = self.config.get('ncaam_hockey_scoreboard', {}).get('enabled', False) ncaaw_hockey_enabled = self.config.get('ncaaw_hockey_scoreboard', {}).get('enabled', False) update_mode('nhl_live', getattr(self, 'nhl_live', None), self.nhl_live_priority, nhl_enabled) update_mode('nba_live', getattr(self, 'nba_live', None), self.nba_live_priority, nba_enabled) + update_mode('wnba_live', getattr(self, 'wnba_live', None), self.wnba_live_priority, wnba_enabled) update_mode('mlb_live', getattr(self, 'mlb_live', None), self.mlb_live_priority, mlb_enabled) update_mode('milb_live', getattr(self, 'milb_live', None), self.milb_live_priority, milb_enabled) update_mode('soccer_live', getattr(self, 'soccer_live', None), self.soccer_live_priority, soccer_enabled) @@ -1050,6 +1074,7 @@ class DisplayController: update_mode('ncaa_fb_live', getattr(self, 'ncaa_fb_live', None), self.ncaa_fb_live_priority, ncaa_fb_enabled) update_mode('ncaa_baseball_live', getattr(self, 'ncaa_baseball_live', None), self.ncaa_baseball_live_priority, ncaa_baseball_enabled) update_mode('ncaam_basketball_live', getattr(self, 'ncaam_basketball_live', None), self.ncaam_basketball_live_priority, ncaam_basketball_enabled) + update_mode('ncaaw_basketball_live', getattr(self, 'ncaaw_basketball_live', None), self.ncaaw_basketball_live_priority, ncaaw_basketball_enabled) update_mode('ncaam_hockey_live', getattr(self, 'ncaam_hockey_live', None), self.ncaam_hockey_live_priority, ncaam_hockey_enabled) update_mode('ncaaw_hockey_live', getattr(self, 'ncaaw_hockey_live', None), self.ncaaw_hockey_live_priority, ncaaw_hockey_enabled) @@ -1094,6 +1119,7 @@ class DisplayController: for sport, attr, priority in [ ('nhl', 'nhl_live', self.nhl_live_priority), ('nba', 'nba_live', self.nba_live_priority), + ('wnba', 'wnba_live', self.wnba_live_priority), ('mlb', 'mlb_live', self.mlb_live_priority), ('milb', 'milb_live', self.milb_live_priority), ('soccer', 'soccer_live', self.soccer_live_priority), @@ -1101,6 +1127,7 @@ class DisplayController: ('ncaa_fb', 'ncaa_fb_live', self.ncaa_fb_live_priority), ('ncaa_baseball', 'ncaa_baseball_live', self.ncaa_baseball_live_priority), ('ncaam_basketball', 'ncaam_basketball_live', self.ncaam_basketball_live_priority), + ('ncaaw_basketball', 'ncaaw_basketball_live', self.ncaaw_basketball_live_priority), ('ncaam_hockey', 'ncaam_hockey_live', self.ncaam_hockey_live_priority), ('ncaaw_hockey', 'ncaaw_hockey_live', self.ncaaw_hockey_live_priority) ]: @@ -1264,6 +1291,10 @@ class DisplayController: manager_to_display = self.nba_recent elif self.current_display_mode == 'nba_upcoming' and self.nba_upcoming: manager_to_display = self.nba_upcoming + elif self.current_display_mode == 'wnba_recent' and self.wnba_recent: + manager_to_display = self.wnba_recent + elif self.current_display_mode == 'wnba_upcoming' and self.wnba_upcoming: + manager_to_display = self.wnba_upcoming elif self.current_display_mode == 'nfl_recent' and self.nfl_recent: manager_to_display = self.nfl_recent elif self.current_display_mode == 'nfl_upcoming' and self.nfl_upcoming: @@ -1280,6 +1311,10 @@ class DisplayController: manager_to_display = self.ncaam_basketball_recent elif self.current_display_mode == 'ncaam_basketball_upcoming' and self.ncaam_basketball_upcoming: manager_to_display = self.ncaam_basketball_upcoming + elif self.current_display_mode == 'ncaaw_basketball_recent' and self.ncaaw_basketball_recent: + manager_to_display = self.ncaaw_basketball_recent + elif self.current_display_mode == 'ncaaw_basketball_upcoming' and self.ncaaw_basketball_upcoming: + manager_to_display = self.ncaaw_basketball_upcoming elif self.current_display_mode == 'mlb_recent' and self.mlb_recent: manager_to_display = self.mlb_recent elif self.current_display_mode == 'mlb_upcoming' and self.mlb_upcoming: @@ -1298,6 +1333,8 @@ class DisplayController: manager_to_display = self.nhl_live elif self.current_display_mode == 'nba_live' and self.nba_live: manager_to_display = self.nba_live + elif self.current_display_mode == 'wnba_live' and self.wnba_live: + manager_to_display = self.wnba_live elif self.current_display_mode == 'nfl_live' and self.nfl_live: manager_to_display = self.nfl_live elif self.current_display_mode == 'ncaa_fb_live' and self.ncaa_fb_live: @@ -1306,6 +1343,8 @@ class DisplayController: manager_to_display = self.ncaa_baseball_live elif self.current_display_mode == 'ncaam_basketball_live' and self.ncaam_basketball_live: manager_to_display = self.ncaam_basketball_live + elif self.current_display_mode == 'ncaaw_basketball_live' and self.ncaaw_basketball_live: + manager_to_display = self.ncaaw_basketball_live elif self.current_display_mode == 'ncaam_hockey_live' and self.ncaam_hockey_live: manager_to_display = self.ncaam_hockey_live elif self.current_display_mode == 'ncaam_hockey_recent' and self.ncaam_hockey_recent: @@ -1383,6 +1422,10 @@ class DisplayController: self.ncaam_basketball_recent.display(force_clear=self.force_clear) elif self.current_display_mode == 'ncaam_basketball_upcoming' and self.ncaam_basketball_upcoming: self.ncaam_basketball_upcoming.display(force_clear=self.force_clear) + elif self.current_display_mode == 'ncaaw_basketball_recent' and self.ncaaw_basketball_recent: + self.ncaaw_basketball_recent.display(force_clear=self.force_clear) + elif self.current_display_mode == 'ncaaw_basketball_upcoming' and self.ncaaw_basketball_upcoming: + self.ncaaw_basketball_upcoming.display(force_clear=self.force_clear) elif self.current_display_mode == 'ncaa_baseball_recent' and self.ncaa_baseball_recent: self.ncaa_baseball_recent.display(force_clear=self.force_clear) elif self.current_display_mode == 'ncaa_baseball_upcoming' and self.ncaa_baseball_upcoming: diff --git a/src/logo_downloader.py b/src/logo_downloader.py index 218b3057..7dfd0c6d 100644 --- a/src/logo_downloader.py +++ b/src/logo_downloader.py @@ -49,6 +49,7 @@ class LogoDownloader: LOGO_DIRECTORIES = { 'nfl': 'assets/sports/nfl_logos', 'nba': 'assets/sports/nba_logos', + 'wnba': 'assets/sports/wnba_logos', 'mlb': 'assets/sports/mlb_logos', 'nhl': 'assets/sports/nhl_logos', # NCAA sports use same directory diff --git a/src/nba_managers.py b/src/nba_managers.py index bc70823a..a4ca4c66 100644 --- a/src/nba_managers.py +++ b/src/nba_managers.py @@ -1,18 +1,13 @@ -import os import time import logging import requests -import json -from typing import Dict, Any, Optional, List -from PIL import Image, ImageDraw, ImageFont +from typing import Dict, Any, Optional from pathlib import Path -from datetime import datetime, timedelta, timezone +from datetime import datetime from src.display_manager import DisplayManager from src.cache_manager import CacheManager -from src.config_manager import ConfigManager -from src.odds_manager import OddsManager -from src.background_data_service import get_background_service -from src.background_cache_mixin import BackgroundCacheMixin +from src.base_classes.basketball import Basketball, BasketballLive +from src.base_classes.sports import SportsRecent, SportsUpcoming import pytz # Import the API counter function from web interface @@ -26,14 +21,7 @@ except ImportError: # Constants ESPN_NBA_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/basketball/nba/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 BaseNBAManager(BackgroundCacheMixin): +class BaseNBAManager(Basketball): """Base class for NBA managers with common functionality.""" # Class variables for warning tracking _no_data_warning_logged = False @@ -42,883 +30,169 @@ class BaseNBAManager(BackgroundCacheMixin): _last_log_times = {} _shared_data = None _last_shared_update = 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.nba_config = config.get("nba_scoreboard", {}) - self.is_enabled = self.nba_config.get("enabled", False) - self.show_odds = self.nba_config.get("show_odds", False) - self.test_mode = self.nba_config.get("test_mode", False) - self.logo_dir = self.nba_config.get("logo_dir", "assets/sports/nba_logos") - self.show_records = self.nba_config.get('show_records', False) - self.update_interval = self.nba_config.get("update_interval_seconds", 300) - self.last_update = 0 - self.current_game = None - self.fonts = self._load_fonts() - self.favorite_teams = self.nba_config.get("favorite_teams", []) - - # Set logging level to INFO to reduce noise - self.logger.setLevel(logging.INFO) - - # 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 = {} - - # Initialize background data service - background_config = self.nba_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"[NBA] Background service enabled with {max_workers} workers") - else: - self.background_service = None - self.background_fetch_requests = {} - self.background_enabled = False - self.logger.info("[NBA] Background service disabled") + self.logger = logging.getLogger('NBA') # Changed logger name + super().__init__(config=config, display_manager=display_manager, cache_manager=cache_manager, logger=self.logger, sport_key="nba") + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("nba_recent", False) + self.upcoming_enabled = display_modes.get("nba_upcoming", False) + self.live_enabled = display_modes.get("nba_live", False) self.logger.info(f"Initialized NBA manager with display dimensions: {self.display_width}x{self.display_height}") self.logger.info(f"Logo directory: {self.logo_dir}") - - 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, message_type: str, cooldown: int = 300) -> bool: - """Check if a message should be logged based on cooldown period.""" - current_time = time.time() - last_time = self._last_log_times.get(message_type, 0) - - if current_time - last_time >= cooldown: - self._last_log_times[message_type] = current_time - return True - return False - - def _load_test_data(self) -> Dict: - """Load test data for development and testing.""" - self.logger.info("[NBA] Loading test data") - - # Create test data with current time - now = datetime.now(timezone.utc) - - # Create test events for different scenarios - events = [] - - # Live game - live_game = { - "date": now.isoformat(), - "competitions": [{ - "status": { - "type": { - "state": "in", - "shortDetail": "Q3 5:23" - }, - "period": 3, - "displayClock": "5:23" - }, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": "LAL"}, - "score": "85" - }, - { - "homeAway": "away", - "team": {"abbreviation": "GSW"}, - "score": "82" - } - ] - }] - } - events.append(live_game) - - # Recent game (yesterday) - recent_game = { - "date": (now - timedelta(days=1)).isoformat(), - "competitions": [{ - "status": { - "type": { - "state": "post", - "shortDetail": "Final" - }, - "period": 4, - "displayClock": "0:00" - }, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": "BOS"}, - "score": "112" - }, - { - "homeAway": "away", - "team": {"abbreviation": "MIA"}, - "score": "108" - } - ] - }] - } - events.append(recent_game) - - # Upcoming game (tomorrow) - upcoming_game = { - "date": (now + timedelta(days=1)).isoformat(), - "competitions": [{ - "status": { - "type": { - "state": "pre", - "shortDetail": "7:30 PM ET" - }, - "period": 0, - "displayClock": "0:00" - }, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": "PHX"}, - "score": "0" - }, - { - "homeAway": "away", - "team": {"abbreviation": "DEN"}, - "score": "0" - } - ] - }] - } - events.append(upcoming_game) - - return {"events": events} - - def _load_fonts(self): - """Load fonts used by the scoreboard.""" - fonts = {} - try: - # Try to load the Press Start 2P font first - fonts['score'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) - fonts['time'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - logging.info("[NBA] Successfully loaded Press Start 2P font for all text elements") - except IOError: - logging.warning("[NBA] 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("[NBA] Successfully loaded 4x6 font for all text elements") - except IOError: - logging.warning("[NBA] 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 _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}") - - if team_abbrev in self._logo_cache: - self.logger.debug(f"Using cached logo for {team_abbrev}") - 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 == "LAL": - color = (253, 185, 39, 255) # Lakers gold - else: - color = (0, 125, 197, 255) # Warriors blue - 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 + self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}") + self.league = "nba" def _fetch_nba_api_data(self, use_cache: bool = True) -> Optional[Dict]: - """Fetch and cache data for all managers to share.""" + """ + Fetches the full season schedule for NBA using background threading. + Returns cached data immediately if available, otherwise starts background fetch. + """ now = datetime.now(pytz.utc) - date_str = now.strftime('%Y%m%d') - cache_key = f"nba_api_data_{date_str}" + season_year = now.year + if now.month < 7: + season_year = now.year - 1 + datestring = f"{season_year}1001-{season_year+1}0701" + cache_key = f"{self.sport_key}_schedule_{season_year}" + # Check cache first if use_cache: cached_data = self.cache_manager.get(cache_key) if cached_data: - self.logger.info(f"[NBA] Using cached data for {date_str}") - 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 {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 background service is disabled, fall back to synchronous fetch + if not self.background_enabled or not self.background_service: + return self._fetch_nba_api_data_sync(use_cache) + + # 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="nba", + year=season_year, + url=ESPN_NBA_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_nba_api_data_sync(self, use_cache: bool = True) -> Optional[Dict]: + """ + Synchronous fallback for fetching NFL data when background service is disabled. + """ + now = datetime.now(pytz.utc) + current_year = now.year + cache_key = f"nba_schedule_{current_year}" + + self.logger.info(f"Fetching full {current_year} season schedule from ESPN API (sync mode)...") try: - url = ESPN_NBA_SCOREBOARD_URL - params = {'dates': date_str} - response = requests.get(url, params=params) + response = self.session.get(ESPN_NBA_SCOREBOARD_URL, params={"dates": current_year, "limit":1000}, headers=self.headers, timeout=15) response.raise_for_status() data = response.json() - - # Increment API counter for sports data call - increment_api_counter('sports', 1) + events = data.get('events', []) if use_cache: - self.cache_manager.set(cache_key, data) - - self.logger.info(f"[NBA] Successfully fetched data from ESPN API for {date_str}") - return data + 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"[NBA] Error fetching data from ESPN: {e}") + self.logger.error(f"API error fetching full schedule: {e}") return None - def _fetch_data(self, date_str: str = None) -> Optional[Dict]: - """ - Fetch data using background service cache first, fallback to direct API call. - This eliminates redundant caching and ensures Recent/Upcoming managers - use the same data source as the background service. - """ - return self._fetch_data_with_background_cache( - sport_key='nba', - api_fetch_method=self._fetch_nba_api_data, - live_manager_class=NBALiveManager - ) - - def _fetch_odds(self, game: Dict) -> None: - """Fetch odds for a specific game if conditions are met.""" - # 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.nba_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')}") - - # Fetch odds using OddsManager (ESPN API) - try: - # Determine update interval based on game state - is_live = game.get('status', '').lower() == 'in' - update_interval = self.nba_config.get("live_odds_update_interval", 60) if is_live \ - else self.nba_config.get("odds_update_interval", 3600) - - odds_data = self.odds_manager.get_odds( - sport="basketball", - league="nba", - event_id=game['id'], - update_interval_seconds=update_interval - ) - - if odds_data: - game['odds'] = odds_data - self.logger.debug(f"Successfully fetched and attached odds for game {game['id']}") - else: - self.logger.debug(f"No odds data returned for game {game['id']}") - - except Exception as e: - self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}") - - 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"[NBA] Parsed game time: {start_time_utc}") - except ValueError: - logging.warning(f"[NBA] 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").lstrip('0') - - # Check date format from config - use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False) - if use_short_date_format: - game_date = local_time.strftime("%-m/%-d") - else: - 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 - } - - # Log game details for debugging - self.logger.debug(f"[NBA] Extracted game details: {details['away_abbr']} vs {details['home_abbr']}") - self.logger.debug(f"[NBA] Game status: is_final={details['is_final']}, is_within_window={details['is_within_window']}") - - return details - except Exception as e: - logging.error(f"[NBA] 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 + 12 - 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 = -12 - 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 - draw.text((status_x, status_y), status_text, font=self.fonts['status'], fill=(255, 255, 255)) - - # 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 - draw.text((date_x, date_y), game_date, font=self.fonts['time'], fill=(255, 255, 255)) - - # 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 - draw.text((time_x, time_y), game_time, font=self.fonts['time'], fill=(255, 255, 255)) - 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 - 10 - draw.text((score_x, score_y), score_text, font=self.fonts['score'], fill=(255, 255, 255)) - - # Draw period and time or Final - if game.get("is_final", False): - status_text = "Final" - status_width = draw.textlength(status_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 5 - draw.text((status_x, status_y), status_text, font=self.fonts['time'], fill=(255, 255, 255)) - else: - period = game.get("period", 0) - clock = game.get("clock", "0:00") - - # Format period text for NBA (quarters) - if period > 4: - period_text = "OT" - else: - period_text = f"{period}{'st' if period == 1 else 'nd' if period == 2 else 'rd' if period == 3 else 'th'} Q" - - # Draw period text at the top - period_width = draw.textlength(period_text, font=self.fonts['time']) - period_x = (self.display_width - period_width) // 2 - period_y = 1 - draw.text((period_x, period_y), period_text, font=self.fonts['time'], fill=(255, 255, 255)) - - # Draw clock below period - clock_width = draw.textlength(clock, font=self.fonts['time']) - clock_x = (self.display_width - clock_width) // 2 - clock_y = period_y + 10 # Position below period - draw.text((clock_x, clock_y), clock, font=self.fonts['time'], fill=(255, 255, 255)) - - # Draw odds if available - if 'odds' in game and game['odds']: - self._draw_dynamic_odds(draw, game['odds'], self.display_width, self.display_height) - - # 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 = 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 = self.display_width - home_record_width - 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 NBA 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("[NBA] No game data available to display") - self._last_warning_time = current_time - return - - self._draw_scorebug_layout(self.current_game, force_clear) - - 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') - - # Get top-level spread as fallback - top_level_spread = odds.get('spread') - - # If we have a top-level spread and the individual spreads are None or 0, use the top-level - if top_level_spread is not None: - if home_spread is None or home_spread == 0.0: - home_spread = top_level_spread - if away_spread is None: - away_spread = -top_level_spread - - # Determine which team is favored (has negative spread) - 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}") + def _fetch_data(self) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, NBALiveManager): + # Live games should fetch only current games, not entire season + 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.fonts['detail'] # Use detail font for odds - - if favored_side == 'home': - # Home team is favored, show spread on right side - spread_width = draw.textlength(spread_text, font=font) - spread_x = width - spread_width # Top right - spread_y = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) - self.logger.debug(f"Showing home spread '{spread_text}' on right side") - else: - # Away team is favored, show spread on left side - spread_x = 0 # Top left - spread_y = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) - self.logger.debug(f"Showing away spread '{spread_text}' on left side") - - # Show over/under on the opposite side of the favored team - over_under = odds.get('over_under') - if over_under is not None: - ou_text = f"O/U: {over_under}" - font = self.fonts['detail'] # Use detail font for odds - ou_width = draw.textlength(ou_text, font=font) - - if favored_side == 'home': - # Home 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)) + # Recent and Upcoming managers should use cached season data + return self._fetch_nba_api_data(use_cache=True) - def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): - """Helper to draw text with an outline.""" - draw.text(position, text, font=font, fill=outline_color) - draw.text(position, text, font=font, fill=fill) - - -class NBALiveManager(BaseNBAManager): +class NBALiveManager(BaseNBAManager, BasketballLive): """Manager for live NBA 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.nba_config.get("live_update_interval", 30) - self.no_data_interval = 300 - self.last_update = 0 - self.logger.info("Initialized NBA Live Manager") - self.live_games = [] - self.current_game_index = 0 - self.last_game_switch = 0 - self.game_display_duration = self.nba_config.get("live_game_duration", 20) - self.last_display_update = 0 - self.last_log_time = 0 - self.log_interval = 300 + self.logger = logging.getLogger('NBALiveManager') # Changed logger name - 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 - - # Fetch live game data - data = self._fetch_data() - new_live_games = [] - if data and "events" in data: - 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.nba_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] - - # Update game list and current game - if new_live_games: - self.live_games = new_live_games - 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] if self.live_games else None - self.last_game_switch = current_time - else: - # Update current game with fresh data - self.current_game = new_live_games[self.current_game_index] - else: - self.live_games = [] - self.current_game = None - - def display(self, force_clear: bool = False) -> None: - """Display live game information.""" - if not self.current_game: - return - super().display(force_clear) + if self.test_mode: + # More detailed test game for NBA + self.current_game = { + "id": "test001", + "home_abbr": "LAL", "home_id": "123", "away_abbr": "GS", "away_id":"asdf", + "home_score": "21", "away_score": "17", + "period": 3, "period_text": "Q3", "clock": "5:24", + "home_logo_path": Path(self.logo_dir, "LAL.png"), + "away_logo_path": Path(self.logo_dir, "GS.png"), + "is_live": True, "is_final": False, "is_upcoming": False, "is_halftime": False, + } + self.live_games = [self.current_game] + self.logger.info("Initialized NBALiveManager with test game: BUF vs KC") + else: + self.logger.info(" Initialized NBALiveManager in live mode") -class NBARecentManager(BaseNBAManager): +class NBARecentManager(BaseNBAManager, SportsRecent): """Manager for recently completed NBA 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.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.nba_config.get("recent_update_interval", 3600) # Use config, default 1 hour - self.recent_games_to_show = self.nba_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('NBARecentManager') # Changed logger name + self.logger.info(f"Initialized NBARecentManager 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 - - try: - data = self._fetch_data() - if not data or 'events' not in data: - return - - events = data['events'] - new_recent_games = [] - for event in events: - game = self._extract_game_details(event) - if game and game['is_final']: - self._fetch_odds(game) - new_recent_games.append(game) - - # Filter for favorite teams only if the config is set - if self.nba_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_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 = new_recent_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.recent_games = team_games - - if self.recent_games: - if not self.current_game or self.current_game['id'] not in {g['id'] for g in self.recent_games}: - self.current_game_index = 0 - self.current_game = self.recent_games[0] - self.last_game_switch = current_time - else: - self.current_game = None - - self.last_update = current_time - - except Exception as e: - self.logger.error(f"[NBA] Error updating recent games: {e}", exc_info=True) - - def display(self, force_clear=False): - """Display recent games.""" - if not self.recent_games: - return - - 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: - 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_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - self.logger.info(f"[NBA Recent] Showing {away_abbr} vs {home_abbr}") - - # Draw the scorebug layout - self._draw_scorebug_layout(self.current_game, force_clear) - - except Exception as e: - self.logger.error(f"[NBA] Error displaying recent game: {e}", exc_info=True) - - -class NBAUpcomingManager(BaseNBAManager): +class NBAUpcomingManager(BaseNBAManager, SportsUpcoming): """Manager for upcoming NBA 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.nba_config.get("upcoming_update_interval", 3600) # Use config, default 1 hour - self.upcoming_games_to_show = self.nba_config.get("upcoming_games_to_show", 5) # Number of upcoming games to display - self.last_game_switch = 0 - self.game_display_duration = 15 # Display each game for 15 seconds + self.logger = logging.getLogger('NBAUpcomingManager') # Changed logger name + self.logger.info(f"Initialized NBAUpcomingManager 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 - - try: - data = self._fetch_data() - if not data or 'events' not in data: - return - - events = data['events'] - new_upcoming_games = [] - for event in events: - game = self._extract_game_details(event) - if game and game['is_upcoming']: - self._fetch_odds(game) - new_upcoming_games.append(game) - - # Filter for favorite teams only if the config is set - if self.nba_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] - 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] - else: - self.current_game = None - - self.last_update = current_time - - except Exception as e: - self.logger.error(f"[NBA] Error updating upcoming games: {e}", exc_info=True) - - def display(self, force_clear=False): """Display upcoming games.""" if not self.upcoming_games: return diff --git a/src/ncaam_basketball_managers.py b/src/ncaam_basketball_managers.py index 18e5db6c..fad9f52d 100644 --- a/src/ncaam_basketball_managers.py +++ b/src/ncaam_basketball_managers.py @@ -1,17 +1,15 @@ -import os -import time import logging -import requests -import json -from typing import Dict, Any, Optional, List -from PIL import Image, ImageDraw, ImageFont +from datetime import datetime from pathlib import Path -from datetime import datetime, timedelta, timezone -from src.display_manager import DisplayManager -from src.cache_manager import CacheManager -from src.config_manager import ConfigManager -from src.odds_manager import OddsManager +from typing import Any, Dict, Optional + import pytz +import requests + +from src.base_classes.basketball import Basketball, BasketballLive +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: @@ -21,18 +19,14 @@ except ImportError: def increment_api_counter(kind: str, count: int = 1): pass + # Constants ESPN_NCAAMB_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/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 BaseNCAAMBasketballManager: +class BaseNCAAMBasketballManager(Basketball): """Base class for NCAA MB managers with common functionality.""" + # Class variables for warning tracking _no_data_warning_logged = False _last_warning_time = 0 @@ -40,1075 +34,241 @@ class BaseNCAAMBasketballManager: _last_log_times = {} _shared_data = None _last_shared_update = 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.ncaam_basketball_config = config.get("ncaam_basketball_scoreboard", {}) - self.is_enabled = self.ncaam_basketball_config.get("enabled", False) - self.show_odds = self.ncaam_basketball_config.get("show_odds", False) - self.test_mode = self.ncaam_basketball_config.get("test_mode", False) - self.logo_dir = self.ncaam_basketball_config.get("logo_dir", "assets/sports/ncaa_logos") - self.update_interval = self.ncaam_basketball_config.get("update_interval_seconds", 60) - self.show_records = self.ncaam_basketball_config.get('show_records', False) - self.last_update = 0 - self.current_game = None - self.fonts = self._load_fonts() - self.favorite_teams = self.ncaam_basketball_config.get("favorite_teams", []) - - # Set logging level to INFO to reduce noise - self.logger.setLevel(logging.INFO) - - # 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 = {} - - self.logger.info(f"Initialized NCAAMBasketball manager with display dimensions: {self.display_width}x{self.display_height}") + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + self.logger = logging.getLogger("NCAAMB") # Changed logger name + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="ncaam_basketball", + ) + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("ncaam_basketball_recent", False) + self.upcoming_enabled = display_modes.get("ncaam_basketball_upcoming", False) + self.live_enabled = display_modes.get("ncaam_basketball_live", False) + + self.logger.info( + f"Initialized NCAA Mens Basketball manager with display dimensions: {self.display_width}x{self.display_height}" + ) self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info( + f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}" + ) + self.league = "mens-college-basketball" - 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.ncaam_basketball_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="basketball", - league="mens-college-basketball", - 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 - - def _should_log(self, message_type: str, cooldown: int = 300) -> bool: - """Check if a message should be logged based on cooldown period.""" - current_time = time.time() - last_time = self._last_log_times.get(message_type, 0) - - if current_time - last_time >= cooldown: - self._last_log_times[message_type] = current_time - return True - return False - - def _load_test_data(self) -> Dict: - """Load test data for development and testing.""" - self.logger.info("[NCAAMBasketball] Loading test data") - - # Create test data with current time - now = datetime.now(self._get_timezone()) - - # Create test events for different scenarios - events = [] - - # Live game (2nd Half) - live_game = { - "date": now.isoformat(), - "competitions": [{ - "status": { - "type": { - "state": "in", - "shortDetail": "H2 5:23" # Changed from Q3 - }, - "period": 2, # Changed from 3 - "displayClock": "5:23" - }, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": "UGA"}, - "score": "75" # Adjusted score - }, - { - "homeAway": "away", - "team": {"abbreviation": "AUB"}, - "score": "72" # Adjusted score - } - ] - }] - } - events.append(live_game) - - # Recent game (yesterday) - recent_game = { - "date": (now - timedelta(days=1)).isoformat(), - "competitions": [{ - "status": { - "type": { - "state": "post", - "shortDetail": "Final" - }, - "period": 2, # Changed from 4 - "displayClock": "0:00" - }, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": "UCLA"}, # Changed from BOS - "score": "88" # Adjusted score - }, - { - "homeAway": "away", - "team": {"abbreviation": "ZAGA"}, # Changed from MIA - "score": "85" # Adjusted score - } - ] - }] - } - events.append(recent_game) - - # Upcoming game (tomorrow) - upcoming_game = { - "date": (now + timedelta(days=1)).isoformat(), - "competitions": [{ - "status": { - "type": { - "state": "pre", - "shortDetail": "8:00 PM ET" # Adjusted time - }, - "period": 0, - "displayClock": "0:00" - }, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": "UGA"}, # Changed from PHX - "score": "0" - }, - { - "homeAway": "away", - "team": {"abbreviation": "AUB"}, # Changed from DEN - "score": "0" - } - ] - }] - } - events.append(upcoming_game) - - return {"events": events} - - def _load_fonts(self): - """Load fonts used by the scoreboard.""" - fonts = {} - try: - fonts['score'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) - fonts['time'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - logging.info("[NCAAMBasketball] Successfully loaded Press Start 2P font for all text elements") - except IOError: - logging.warning("[NCAAMBasketball] 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("[NCAAMBasketball] Successfully loaded 4x6 font for all text elements") - except IOError: - logging.warning("[NCAAMBasketball] 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 _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}") - - if team_abbrev in self._logo_cache: - self.logger.debug(f"Using cached logo for {team_abbrev}") - 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 (Simple placeholder logic) - 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) - logo = Image.new('RGBA', (32, 32), (0, 0, 0, 0)) - draw = ImageDraw.Draw(logo) - # Basic color logic for test logos - color = (sum(ord(c) for c in team_abbrev) % 200 + 55, # R - sum(ord(c)**2 for c in team_abbrev) % 200 + 55, # G - sum(ord(c)**3 for c in team_abbrev) % 200 + 55, # B - 255) # Alpha - draw.rectangle([4, 4, 28, 28], fill=color) - 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 - 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 _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): + def _fetch_ncaam_basketball_api_data( + self, use_cache: bool = True + ) -> Optional[Dict]: """ - 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) + Fetches the full season schedule for NCAA Mens Basketball using background threading. + Returns cached data immediately if available, otherwise starts background fetch. """ - 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 _fetch_ncaam_basketball_api_data(self, use_cache: bool = True) -> Optional[Dict]: - """Fetch and cache data for all managers to share.""" now = datetime.now(pytz.utc) - date_str = now.strftime('%Y%m%d') - cache_key = f"ncaam_basketball_{date_str}" + season_year = now.year + cache_key = f"{self.sport_key}_schedule_{season_year}" + # Check cache first if use_cache: cached_data = self.cache_manager.get(cache_key) if cached_data: - self.logger.info(f"[NCAAMBasketball] Using cached data for {date_str}") - 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 {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 background service is disabled, fall back to synchronous fetch + if not self.background_enabled or not self.background_service: + return self._fetch_ncaam_basketball_api_data_sync(use_cache) + + # 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_basketball", + year=season_year, + url=ESPN_NCAAMB_SCOREBOARD_URL, + cache_key=cache_key, + params={"dates": season_year, "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_ncaam_basketball_api_data_sync( + self, use_cache: bool = True + ) -> Optional[Dict]: + """ + Synchronous fallback for fetching NFL data when background service is disabled. + """ + now = datetime.now(pytz.utc) + current_year = now.year + cache_key = f"ncaa_mens_basketball_schedule_{current_year}" + + self.logger.info( + f"Fetching full {current_year} season schedule from ESPN API (sync mode)..." + ) try: - url = ESPN_NCAAMB_SCOREBOARD_URL - params = {'dates': date_str} - response = requests.get(url, params=params) + response = self.session.get( + ESPN_NCAAMB_SCOREBOARD_URL, + params={"dates": current_year, "limit": 1000}, + headers=self.headers, + timeout=15, + ) response.raise_for_status() data = response.json() - - # Increment API counter for sports data - increment_api_counter('sports', 1) - + events = data.get("events", []) + if use_cache: - self.cache_manager.set(cache_key, data) - - self.logger.info(f"[NCAAMBasketball] Successfully fetched data from ESPN API for {date_str}") - return data - except requests.exceptions.RequestException as e: - self.logger.error(f"[NCAAMBasketball] Error fetching data from ESPN: {e}") - return None + self.cache_manager.set(cache_key, events) - def _fetch_data(self, date_str: str = None) -> Optional[Dict]: - """ - Fetch data using background service cache first, fallback to direct API call. - This eliminates redundant caching and ensures Recent/Upcoming managers - use the same data source as the background service. - """ - # For Live managers, always fetch fresh data - if isinstance(self, NCAAMBasketballLiveManager): - return self._fetch_ncaam_basketball_api_data(use_cache=False) - - # For Recent/Upcoming managers, try to use background service cache first - from datetime import datetime - import pytz - cache_key = f"ncaam_basketball_{datetime.now(pytz.utc).strftime('%Y%m%d')}" - - # Check if background service has fresh data - if self.cache_manager.is_background_data_available(cache_key, 'ncaam_basketball'): - cached_data = self.cache_manager.get_background_cached_data(cache_key, 'ncaam_basketball') - if cached_data: - self.logger.info(f"[NCAAMBasketball] Using background service cache for {cache_key}") - return cached_data - - # Fallback to direct API call if background data not available - self.logger.info(f"[NCAAMBasketball] Background data not available, fetching directly for {cache_key}") - return self._fetch_ncaam_basketball_api_data(use_cache=True) - - 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"[NCAAMBasketball] Parsed game time: {start_time_utc}") - except ValueError: - logging.warning(f"[NCAAMBasketball] 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").lstrip('0') - - # Check date format from config - use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False) - if use_short_date_format: - game_date = local_time.strftime("%-m/%-d") - else: - 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"), # Include halftime as live - "is_halftime": status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_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"[NCAAMBasketball] Extracted game details: {details['away_abbr']} vs {details['home_abbr']}") - self.logger.debug(f"[NCAAMBasketball] Game status: is_final={details['is_final']}, is_within_window={details['is_within_window']}") - - return details - except Exception as e: - logging.error(f"[NCAAMBasketball] 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 + 12 - 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 = -12 - 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 - 10 - 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" - 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']) - elif game.get("is_halftime", False): - status_text = "Halftime" - 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']) - else: - period = game.get("period", 0) - clock = game.get("clock", "0:00") - - # Format period text for NCAA MB (Halves/OT) - if period == 1: - period_text = "1st H" - elif period == 2: - period_text = "2nd H" - elif period == 3: - period_text = "OT" - elif period > 3: - period_text = f"{period - 2}OT" # 2OT, 3OT etc. - else: - period_text = "" # Should not happen in live game normally - - # Draw period text at the top - period_width = draw.textlength(period_text, font=self.fonts['time']) - period_x = (self.display_width - period_width) // 2 - period_y = 1 - self._draw_text_with_outline(draw, period_text, (period_x, period_y), self.fonts['time']) - - # Draw clock below period - clock_width = draw.textlength(clock, font=self.fonts['time']) - clock_x = (self.display_width - clock_width) // 2 - clock_y = period_y + 10 # Position below period - self._draw_text_with_outline(draw, clock, (clock_x, clock_y), self.fonts['time']) - - # Display odds if available - if 'odds' in game and game['odds']: - self._draw_dynamic_odds(draw, game['odds'], self.display_width, self.display_height) - - # 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 = 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 = self.display_width - home_record_width - 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 NCAAMBasketball 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("[NCAAMBasketball] No game data available to display") - self._last_warning_time = current_time - return - - self._draw_scorebug_layout(self.current_game, force_clear) - -class NCAAMBasketballLiveManager(BaseNCAAMBasketballManager): - """Manager for live NCAA MB 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.ncaam_basketball_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 NCAAMBasketball 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.ncaam_basketball_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.has_favorite_team_game = False # Track if we have any favorite team games - - # Initialize with test game only if test mode is enabled - if self.test_mode: - # Use the live game from _load_test_data - test_data = self._load_test_data() - live_test_event = next((e for e in test_data.get("events", []) if e["competitions"][0]["status"]["type"]["state"] == "in"), None) - if live_test_event: - self.current_game = self._extract_game_details(live_test_event) - if self.current_game: - self.live_games = [self.current_game] - self.logger.info(f"[NCAAMBasketball] Initialized NCAAMBasketballLiveManager with test game: {self.current_game['away_abbr']} vs {self.current_game['home_abbr']}") - else: - self.logger.warning("[NCAAMBasketball] Could not find live test game data to initialize.") - else: - self.logger.info("[NCAAMBasketball] Initialized NCAAMBasketballLiveManager in live mode") - - def update(self): - """Update live game data.""" - current_time = time.time() - - # Determine update interval based on whether we have favorite team games - if self.has_favorite_team_game: - interval = self.update_interval # Short interval for live favorite team games - else: - interval = self.no_data_interval # Longer interval when no favorite team games live - - if current_time - self.last_update >= interval: - self.last_update = current_time - - if self.test_mode: - # For testing, update the clock and maybe period - if self.current_game: - try: # Add try-except for robust clock parsing - minutes_str, seconds_str = self.current_game["clock"].split(":") - minutes = int(minutes_str) - seconds = int(seconds_str) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - # Simulate moving from H1 to H2 or H2 to OT - if self.current_game["period"] == 1: - self.current_game["period"] = 2 - minutes = 19 # Reset clock for H2 - seconds = 59 - elif self.current_game["period"] == 2: - self.current_game["period"] = 3 # Go to OT - minutes = 4 # Reset clock for OT - seconds = 59 - elif self.current_game["period"] >= 3: # OT+ - self.current_game["period"] += 1 - minutes = 4 - seconds = 59 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Always update display in test mode - self.display(force_clear=True) - except ValueError: - self.logger.warning(f"[NCAAMBasketball] Could not parse clock in test mode: {self.current_game.get('clock')}") - 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 = [] - has_favorite_team = False - for event in data["events"]: - details = self._extract_game_details(event) - if details and details["is_live"]: # is_live includes 'in' and 'halftime' - # Filter for favorite teams only if the config is set - if self.ncaam_basketball_config.get("show_favorite_teams_only", False): - if not (details["home_abbr"] in self.favorite_teams or details["away_abbr"] in self.favorite_teams): - continue - - self._fetch_odds(details) - new_live_games.append(details) - if self.favorite_teams and ( - details["home_abbr"] in self.favorite_teams or - details["away_abbr"] in self.favorite_teams - ): - has_favorite_team = True - - # Update favorite team game status - self.has_favorite_team_game = has_favorite_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 or # Log if we had no games before - has_favorite_team != self.has_favorite_team_game # Log if favorite team status changed - ) - - if should_log: - if new_live_games: - filter_text = "favorite teams" if self.ncaam_basketball_config.get("show_favorite_teams_only", False) else "all teams" - self.logger.info(f"[NCAAMBasketball] Found {len(new_live_games)} live games involving {filter_text}") - for game in new_live_games: - period = game.get('period', 0) - if game.get('is_halftime'): - status_str = "Halftime" - elif period == 1: - status_str = "H1" - elif period == 2: - status_str = "H2" - elif period == 3: - status_str = "OT" - elif period > 3: - status_str = f"{period-2}OT" - else: - status_str = f"P{period}" # Fallback - self.logger.info(f"[NCAAMBasketball] Live game: {game['away_abbr']} vs {game['home_abbr']} - {status_str}, {game['clock']}") - if has_favorite_team: - self.logger.info("[NCAAMBasketball] Found live game(s) for favorite team(s)") - else: - filter_text = "favorite teams" if self.ncaam_basketball_config.get("show_favorite_teams_only", False) else "criteria" - self.logger.info(f"[NCAAMBasketball] 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 if it matches - current_game_updated = False - if self.current_game: # Ensure current_game is not None - for new_game in new_live_games: - if (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 - current_game_updated = True - break - - # Only update the games list if there's a structural change - 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, it's not in the new list, or the list was empty, reset - if not self.current_game or not current_game_updated or not self.live_games: # Check self.live_games is not empty - self.current_game_index = 0 - self.current_game = self.live_games[0] if self.live_games else None # Handle empty self.live_games - self.last_game_switch = current_time - - # Cycle through games if multiple are present - elif 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"[NCAAMBASKETBALL Live] Showing {away_abbr} vs {home_abbr}") - - - # 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: - # No live games found - self.live_games = [] - self.current_game = None - self.has_favorite_team_game = False - - def display(self, force_clear: bool = False): - """Display live game information.""" - if not self.current_game: - # Explicitly clear display if there's nothing to show - img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) - self.display_manager.image.paste(img, (0, 0)) - self.display_manager.update_display() - return - super().display(force_clear) # Call parent class's display method - -class NCAAMBasketballRecentManager(BaseNCAAMBasketballManager): - """Manager for recently completed NCAA MB 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.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.ncaam_basketball_config.get("recent_update_interval", 3600) # Use config, default 1 hour - self.recent_games_to_show = self.ncaam_basketball_config.get("recent_games_to_show", 5) # Number of most recent games to display - self.last_game_switch = 0 - self.game_display_duration = self.ncaam_basketball_config.get("recent_game_duration", 15) # Configurable duration - 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.logger.info(f"Initialized NCAAMBasketballRecentManager 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 - - try: - # Fetch data from ESPN API (uses shared cache) - data = self._fetch_data() - if not data or 'events' not in data: - if self._should_log("no_events", 600): # Log less frequently for no events - self.logger.warning("[NCAAMBasketball] No events found in ESPN API response for recent games") - self.recent_games = [] - self.current_game = None - self.last_update = current_time - return - - events = data['events'] - - # Process games - new_recent_games = [] - for event in events: - game = self._extract_game_details(event) - # Filter for recent games: must be final - if game and game['is_final']: - self._fetch_odds(game) - new_recent_games.append(game) - - # Filter for favorite teams only if the config is set - if self.ncaam_basketball_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_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) - new_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', datetime.min.replace(tzinfo=timezone.utc)), reverse=True) - new_team_games.append(team_specific_games[0]) - - # Sort the final list by game time (most recent first) - new_team_games.sort(key=lambda g: g.get('start_time_utc', datetime.min.replace(tzinfo=timezone.utc)), reverse=True) - else: - new_team_games = new_recent_games - # Sort by game time (most recent first), then limit to recent_games_to_show - new_team_games.sort(key=lambda g: g.get('start_time_utc', datetime.min.replace(tzinfo=timezone.utc)), reverse=True) - new_team_games = new_team_games[:self.recent_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(new_team_games) != len(self.recent_games) or - (new_team_games and not self.recent_games) # Log if we found games after having none + 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 - if should_log: - if new_team_games: - self.logger.info(f"[NCAAMBasketball] Found {len(new_team_games)} recent games for favorite teams (limited to {self.recent_games_to_show})") - elif self.favorite_teams: # Only log "none found" if favorites are configured - self.logger.info("[NCAAMBasketball] No recent games found for favorite teams") - self.last_log_time = current_time - - if new_team_games: - # Check if the games list actually changed before resetting index - if (len(new_team_games) != len(self.recent_games) or - any(g1 != g2 for g1, g2 in zip(new_team_games, self.recent_games))): - self.recent_games = new_team_games - self.current_game_index = 0 - self.current_game = self.recent_games[0] - self.last_game_switch = current_time # Reset switch timer on list update - else: - self.recent_games = [] - self.current_game = None + def _fetch_data(self) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, NCAAMBasketballLiveManager): + # Live games should fetch only current games, not entire season + return self._fetch_todays_games() + else: + # Recent and Upcoming managers should use cached season data + return self._fetch_ncaam_basketball_api_data(use_cache=True) - self.last_update = current_time +class NCAAMBasketballLiveManager(BaseNCAAMBasketballManager, BasketballLive): + """Manager for live NCAA MB games.""" - except Exception as e: - self.logger.error(f"[NCAAMBasketball] Error updating recent games: {e}", exc_info=True) - self.recent_games = [] # Clear games on error - self.current_game = None - self.last_update = current_time # Still update time to prevent fast retry loops - - def display(self, force_clear=False): - """Display recent games.""" - if not self.recent_games: - current_time = time.time() - if current_time - self.last_warning_time > self.warning_cooldown: - # Only log if favorite teams are configured - if self.favorite_teams: - self.logger.info("[NCAAMBasketball] No recent games for favorite teams to display") - else: - self.logger.info("[NCAAMBasketball] No recent games to display") - self.last_warning_time = current_time - # Explicitly clear display if there's nothing to show - img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) - self.display_manager.image.paste(img, (0, 0)) - self.display_manager.update_display() - return - - try: - current_time = time.time() - - # Check if it's time to switch games (only if more than one game) - 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"[NCAAMBASKETBALL Recent] Showing {away_abbr} vs {home_abbr}") - - # If only one game, ensure it's set correctly - elif len(self.recent_games) == 1: - self.current_game = self.recent_games[0] - - # Draw the scorebug layout - if self.current_game: # Ensure we have a game before drawing - self._draw_scorebug_layout(self.current_game, force_clear) - # Update display - self.display_manager.update_display() - else: - self.logger.warning("[NCAAMBasketball] Current game is None in RecentManager display, despite having recent_games list.") - - - except Exception as e: - self.logger.error(f"[NCAAMBasketball] Error displaying recent game: {e}", exc_info=True) - -class NCAAMBasketballUpcomingManager(BaseNCAAMBasketballManager): - """Manager for upcoming NCAA MB games.""" - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): + 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.ncaam_basketball_config.get("upcoming_update_interval", 3600) # Use config, default 1 hour - self.upcoming_games_to_show = self.ncaam_basketball_config.get("upcoming_games_to_show", 5) # Number of upcoming games to display - self.last_warning_time = 0 - self.warning_cooldown = 300 # Only show warning every 5 minutes - self.last_game_switch = 0 - self.game_display_duration = self.ncaam_basketball_config.get("upcoming_game_duration", 15) # Configurable duration - self.logger.info(f"Initialized NCAAMBasketballUpcomingManager with {len(self.favorite_teams)} favorite teams") + self.logger = logging.getLogger( + "NCAAMBasketballLiveManager" + ) # Changed logger name - def update(self): - """Update upcoming games data.""" - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - - try: - # Fetch data from ESPN API (uses shared cache) - data = self._fetch_data() - if not data or 'events' not in data: - if self._should_log("no_events_upcoming", 600): - self.logger.warning("[NCAAMBasketball] No events found in ESPN API response for upcoming games") - self.upcoming_games = [] - self.current_game = None - self.last_update = current_time - return - - events = data['events'] - if self._should_log("fetch_success_upcoming", 300): - self.logger.info(f"[NCAAMBasketball] Successfully fetched {len(events)} events from ESPN API for upcoming check") - - # Process games - new_upcoming_games = [] - for event in events: - game = self._extract_game_details(event) - if game and game['is_upcoming']: - self._fetch_odds(game) - new_upcoming_games.append(game) - self.logger.debug(f"Processing upcoming game: {game['away_abbr']} vs {game['home_abbr']}") - - # Filter for favorite teams only if the config is set - if self.ncaam_basketball_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', 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', datetime.max.replace(tzinfo=timezone.utc))) - else: - team_games = new_upcoming_games - # Sort by game time (soonest first), then limit to configured count - team_games.sort(key=lambda g: g.get('start_time_utc', datetime.max.replace(tzinfo=timezone.utc))) - team_games = team_games[:self.upcoming_games_to_show] + if self.test_mode: + # More detailed test game for NCAA MB + self.current_game = { + "id": "test001", + "home_abbr": "AUB", + "home_id": "123", + "away_abbr": "GT", + "away_id": "asdf", + "home_score": "21", + "away_score": "17", + "period": 3, + "period_text": "Q3", + "clock": "5:24", + "home_logo_path": Path(self.logo_dir, "AUB.png"), + "away_logo_path": Path(self.logo_dir, "GT.png"), + "is_live": True, + "is_final": False, + "is_upcoming": False, + "is_halftime": False, + } + self.live_games = [self.current_game] + self.logger.info( + "Initialized NCAAMBasketballLiveManager with test game: GT vs AUB" + ) + else: + self.logger.info(" Initialized NCAAMBasketballLiveManager in live mode") - if self._should_log("team_games_upcoming", 300): - if team_games: - self.logger.info(f"[NCAAMBasketball] Found {len(team_games)} upcoming games for favorite teams (limited to {self.upcoming_games_to_show})") - elif self.favorite_teams: # Only log "none found" if favorites configured - self.logger.info("[NCAAMBasketball] No upcoming games found for favorite teams") +class NCAAMBasketballRecentManager(BaseNCAAMBasketballManager, SportsRecent): + """Manager for recently completed NCAA MB games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger( + "NCAAMBasketballRecentManager" + ) # Changed logger name + self.logger.info( + f"Initialized NCAAMBasketballRecentManager with {len(self.favorite_teams)} favorite teams" + ) - if team_games: - # Check if the games list actually changed before resetting index - if (len(team_games) != len(self.upcoming_games) or - any(g1 != g2 for g1, g2 in zip(team_games, self.upcoming_games))): - self.upcoming_games = team_games - self.current_game_index = 0 - self.current_game = self.upcoming_games[0] - self.last_game_switch = current_time # Reset switch timer - else: - self.upcoming_games = [] - self.current_game = None +class NCAAMBasketballUpcomingManager(BaseNCAAMBasketballManager, SportsUpcoming): + """Manager for upcoming NCAA MB games.""" - self.last_update = current_time - - except Exception as e: - self.logger.error(f"[NCAAMBasketball] Error updating upcoming games: {e}", exc_info=True) - self.upcoming_games = [] # Clear games on error - self.current_game = None - self.last_update = current_time # Still update time - - def display(self, force_clear=False): - """Display upcoming games.""" - if not self.upcoming_games: - current_time = time.time() - if current_time - self.last_warning_time > self.warning_cooldown: - if self.favorite_teams: - self.logger.info("[NCAAMBasketball] No upcoming games for favorite teams to display") - else: - self.logger.info("[NCAAMBasketball] No upcoming games to display") - self.last_warning_time = current_time - # Explicitly clear display if there's nothing to show - img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) - self.display_manager.image.paste(img, (0, 0)) - self.display_manager.update_display() - return - - try: - current_time = time.time() - - # Check if it's time to switch games (only if more than one game) - 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"[NCAAMBASKETBALL Upcoming] Showing {away_abbr} vs {home_abbr}") - - # If only one game, ensure it's set - elif len(self.upcoming_games) == 1: - self.current_game = self.upcoming_games[0] - - - # Draw the scorebug layout - if self.current_game: # Ensure we have a game to draw - self._draw_scorebug_layout(self.current_game, force_clear) - # Update display - self.display_manager.update_display() - else: - self.logger.warning("[NCAAMBasketball] Current game is None in UpcomingManager display, despite having upcoming_games list.") - - - except Exception as e: - self.logger.error(f"[NCAAMBasketball] Error displaying upcoming game: {e}", exc_info=True) \ No newline at end of file + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger( + "NCAAMBasketballUpcomingManager" + ) # Changed logger name + self.logger.info( + f"Initialized NCAAMBasketballUpcomingManager with {len(self.favorite_teams)} favorite teams" + ) diff --git a/src/ncaaw_basketball_managers.py b/src/ncaaw_basketball_managers.py new file mode 100644 index 00000000..2b865e0e --- /dev/null +++ b/src/ncaaw_basketball_managers.py @@ -0,0 +1,274 @@ +import logging +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional + +import pytz +import requests + +from src.base_classes.basketball import Basketball, BasketballLive +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: + from web_interface_v2 import increment_api_counter +except ImportError: + # Fallback if web interface is not available + def increment_api_counter(kind: str, count: int = 1): + pass + + +# Constants +ESPN_NCAAWB_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard" + + +class BaseNCAAWBasketballManager(Basketball): + """Base class for NCAA WB managers with common functionality.""" + + # Class variables for warning tracking + _no_data_warning_logged = False + _last_warning_time = 0 + _warning_cooldown = 60 # Only log warnings once per minute + _last_log_times = {} + _shared_data = None + _last_shared_update = 0 + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + self.logger = logging.getLogger("NCAAWB") # Changed logger name + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="ncaaw_basketball", + ) + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("ncaaw_basketball_recent", False) + self.upcoming_enabled = display_modes.get("ncaaw_basketball_upcoming", False) + self.live_enabled = display_modes.get("ncaaw_basketball_live", False) + + self.logger.info( + f"Initialized NCAA Womens Basketball manager with display dimensions: {self.display_width}x{self.display_height}" + ) + self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info( + f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}" + ) + self.league = "womens-college-basketball" + + def _fetch_ncaaw_basketball_api_data( + self, use_cache: bool = True + ) -> Optional[Dict]: + """ + Fetches the full season schedule for NCAA Womens Basketball using background threading. + Returns cached data immediately if available, otherwise starts background fetch. + """ + now = datetime.now(pytz.utc) + season_year = now.year + cache_key = f"{self.sport_key}_schedule_{season_year}" + + # Check cache first + if use_cache: + cached_data = self.cache_manager.get(cache_key) + if cached_data: + # Validate cached data structure + if isinstance(cached_data, dict) and "events" in cached_data: + self.logger.info(f"Using cached schedule for {season_year}") + return cached_data + elif isinstance(cached_data, list): + # Handle old cache format (list of events) + self.logger.info( + f"Using cached schedule for {season_year} (legacy format)" + ) + return {"events": cached_data} + else: + self.logger.warning( + f"Invalid cached data format for {season_year}: {type(cached_data)}" + ) + # Clear invalid cache + self.cache_manager.clear_cache(cache_key) + + # If background service is disabled, fall back to synchronous fetch + if not self.background_enabled or not self.background_service: + return self._fetch_ncaaw_basketball_api_data_sync(use_cache) + + # 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_womens_basketball", + year=season_year, + url=ESPN_NCAAWB_SCOREBOARD_URL, + cache_key=cache_key, + params={"dates": season_year, "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_ncaaw_basketball_api_data_sync( + self, use_cache: bool = True + ) -> Optional[Dict]: + """ + Synchronous fallback for fetching NFL data when background service is disabled. + """ + now = datetime.now(pytz.utc) + current_year = now.year + cache_key = f"ncaa_womens_basketball_schedule_{current_year}" + + self.logger.info( + f"Fetching full {current_year} season schedule from ESPN API (sync mode)..." + ) + try: + response = self.session.get( + ESPN_NCAAWB_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 + + def _fetch_data(self) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, NCAAWBasketballLiveManager): + # Live games should fetch only current games, not entire season + return self._fetch_todays_games() + else: + # Recent and Upcoming managers should use cached season data + return self._fetch_ncaaw_basketball_api_data(use_cache=True) + + +class NCAAWBasketballLiveManager(BaseNCAAWBasketballManager, BasketballLive): + """Manager for live NCAA WB games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger( + "NCAAWBasketballLiveManager" + ) # Changed logger name + + if self.test_mode: + # More detailed test game for NCAA WB + self.current_game = { + "id": "test001", + "home_abbr": "AUB", + "home_id": "123", + "away_abbr": "GT", + "away_id": "asdf", + "home_score": "21", + "away_score": "17", + "period": 3, + "period_text": "Q3", + "clock": "5:24", + "home_logo_path": Path(self.logo_dir, "AUB.png"), + "away_logo_path": Path(self.logo_dir, "GT.png"), + "is_live": True, + "is_final": False, + "is_upcoming": False, + "is_halftime": False, + } + self.live_games = [self.current_game] + self.logger.info( + "Initialized NCAAWBasketballLiveManager with test game: GT vs AUB" + ) + else: + self.logger.info(" Initialized NCAAWBasketballLiveManager in live mode") + + +class NCAAWBasketballRecentManager(BaseNCAAWBasketballManager, SportsRecent): + """Manager for recently completed NCAA WB games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger( + "NCAAWBasketballRecentManager" + ) # Changed logger name + self.logger.info( + f"Initialized NCAAWBasketballRecentManager with {len(self.favorite_teams)} favorite teams" + ) + + +class NCAAWBasketballUpcomingManager(BaseNCAAWBasketballManager, SportsUpcoming): + """Manager for upcoming NCAA WB games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger( + "NCAAWBasketballUpcomingManager" + ) # Changed logger name + self.logger.info( + f"Initialized NCAAWBasketballUpcomingManager with {len(self.favorite_teams)} favorite teams" + ) diff --git a/src/nfl_managers.py b/src/nfl_managers.py index 730e0669..49e7ff2b 100644 --- a/src/nfl_managers.py +++ b/src/nfl_managers.py @@ -23,13 +23,10 @@ class BaseNFLManager(Football): # Renamed class _warning_cooldown = 60 # Only log warnings once per minute _shared_data = None _last_shared_update = 0 - + def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): self.logger = logging.getLogger('NFL') # Changed logger name super().__init__(config=config, display_manager=display_manager, cache_manager=cache_manager, logger=self.logger, sport_key="nfl") - - # Configuration is already set in base class - # self.logo_dir and self.update_interval are already configured # Check display modes to determine what data to fetch display_modes = self.mode_config.get("display_modes", {}) diff --git a/src/wnba_managers.py b/src/wnba_managers.py new file mode 100644 index 00000000..88fc918f --- /dev/null +++ b/src/wnba_managers.py @@ -0,0 +1,304 @@ +import logging +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional + +import pytz +import requests + +from src.base_classes.basketball import Basketball, BasketballLive +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: + from web_interface_v2 import increment_api_counter +except ImportError: + # Fallback if web interface is not available + def increment_api_counter(kind: str, count: int = 1): + pass + + +# Constants +ESPN_WNBA_SCOREBOARD_URL = ( + "https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/scoreboard" +) + + +class BaseWNBAManager(Basketball): + """Base class for WNBA managers with common functionality.""" + + # Class variables for warning tracking + _no_data_warning_logged = False + _last_warning_time = 0 + _warning_cooldown = 60 # Only log warnings once per minute + _last_log_times = {} + _shared_data = None + _last_shared_update = 0 + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + self.logger = logging.getLogger("WNBA") # Changed logger name + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="wnba", + ) + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("wnba_recent", False) + self.upcoming_enabled = display_modes.get("wnba_upcoming", False) + self.live_enabled = display_modes.get("wnba_live", False) + + self.logger.info( + f"Initialized WNBA manager with display dimensions: {self.display_width}x{self.display_height}" + ) + self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info( + f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}" + ) + self.league = "wnba" + + def _fetch_wnba_api_data(self, use_cache: bool = True) -> Optional[Dict]: + """ + Fetches the full season schedule for WNBA using background threading. + Returns cached data immediately if available, otherwise starts background fetch. + """ + now = datetime.now(pytz.utc) + season_year = now.year + if now.month < 2: + season_year = now.year - 1 + datestring = f"{season_year}0401-{season_year+1}1101" + cache_key = f"{self.sport_key}_schedule_{season_year}" + + # Check cache first + if use_cache: + cached_data = self.cache_manager.get(cache_key) + if cached_data: + # Validate cached data structure + if isinstance(cached_data, dict) and "events" in cached_data: + self.logger.info(f"Using cached schedule for {season_year}") + return cached_data + elif isinstance(cached_data, list): + # Handle old cache format (list of events) + self.logger.info( + f"Using cached schedule for {season_year} (legacy format)" + ) + return {"events": cached_data} + else: + self.logger.warning( + f"Invalid cached data format for {season_year}: {type(cached_data)}" + ) + # Clear invalid cache + self.cache_manager.clear_cache(cache_key) + + # If background service is disabled, fall back to synchronous fetch + if not self.background_enabled or not self.background_service: + return self._fetch_wnba_api_data_sync(use_cache) + + # 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="nba", + year=season_year, + url=ESPN_WNBA_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_wnba_api_data_sync(self, use_cache: bool = True) -> Optional[Dict]: + """ + Synchronous fallback for fetching WNBA data when background service is disabled. + """ + now = datetime.now(pytz.utc) + current_year = now.year + cache_key = f"nba_schedule_{current_year}" + + self.logger.info( + f"Fetching full {current_year} season schedule from ESPN API (sync mode)..." + ) + try: + response = self.session.get( + ESPN_WNBA_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 + + def _fetch_data(self) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, WNBALiveManager): + # Live games should fetch only current games, not entire season + return self._fetch_todays_games() + else: + # Recent and Upcoming managers should use cached season data + return self._fetch_wnba_api_data(use_cache=True) + + +class WNBALiveManager(BaseWNBAManager, BasketballLive): + """Manager for live NBA games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("WNBALiveManager") # Changed logger name + + if self.test_mode: + # More detailed test game for NBA + self.current_game = { + "id": "test001", + "home_abbr": "CHI", + "home_id": "123", + "away_abbr": "ATL", + "away_id": "asdf", + "home_score": "21", + "away_score": "17", + "period": 3, + "period_text": "Q3", + "clock": "5:24", + "home_logo_path": Path(self.logo_dir, "CHI.png"), + "away_logo_path": Path(self.logo_dir, "ATL.png"), + "is_live": True, + "is_final": False, + "is_upcoming": False, + "is_halftime": False, + } + self.live_games = [self.current_game] + self.logger.info("Initialized WNBALiveManager with test game: BUF vs KC") + else: + self.logger.info(" Initialized WNBALiveManager in live mode") + + +class WNBARecentManager(BaseWNBAManager, SportsRecent): + """Manager for recently completed WNBA games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("WNBARecentManager") # Changed logger name + self.logger.info( + f"Initialized WNBARecentManager with {len(self.favorite_teams)} favorite teams" + ) + + +class WNBAUpcomingManager(BaseWNBAManager, SportsUpcoming): + """Manager for upcoming WNBA games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("WNBAUpcomingManager") # Changed logger name + self.logger.info( + f"Initialized WNBAUpcomingManager with {len(self.favorite_teams)} favorite teams" + ) + + """Display upcoming games.""" + if not self.upcoming_games: + return + + 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 + + # 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"[NBA Upcoming] Showing {away_abbr} vs {home_abbr}" + ) + + # Draw the scorebug layout + self._draw_scorebug_layout(self.current_game, force_clear) + + except Exception as e: + self.logger.error( + f"[NBA] Error displaying upcoming game: {e}", exc_info=True + )