diff --git a/assets/sports/ncaa_logos/NE.png b/assets/sports/ncaa_logos/NE.png new file mode 100644 index 00000000..9f672ece Binary files /dev/null and b/assets/sports/ncaa_logos/NE.png differ diff --git a/config/config.template.json b/config/config.template.json index a7e30b74..9696e944 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -425,6 +425,32 @@ "ncaam_hockey_upcoming": true } }, + "ncaaw_hockey_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_games_to_show": 1, + "upcoming_games_to_show": 1, + "show_favorite_teams_only": true, + "show_all_live": false, + "favorite_teams": [ + "RIT" + ], + "logo_dir": "assets/sports/ncaa_logos", + "show_records": false, + "show_ranking": false, + "display_modes": { + "ncaaw_hockey_live": true, + "ncaaw_hockey_recent": true , + "ncaaw_hockey_upcoming": true + } + }, "youtube": { "enabled": false, "update_interval": 3600 diff --git a/src/base_classes/hockey.py b/src/base_classes/hockey.py index 078ef3d4..c98f896d 100644 --- a/src/base_classes/hockey.py +++ b/src/base_classes/hockey.py @@ -79,6 +79,7 @@ class Hockey(SportsCore): away_shots = round(home_team_saves / home_team_saves_per) if away_team_saves_per > 0: home_shots = round(away_team_saves / away_team_saves_per) + status_short = status["type"].get("shortDetail", "") if situation and status["type"]["state"] == "in": # Detect scoring events from status detail diff --git a/src/base_classes/sports.py b/src/base_classes/sports.py index 02c15b9b..3c0d0585 100644 --- a/src/base_classes/sports.py +++ b/src/base_classes/sports.py @@ -419,9 +419,14 @@ 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 - - home_abbr = home_team["team"]["abbreviation"] - away_abbr = away_team["team"]["abbreviation"] + try: + home_abbr = home_team["team"]["abbreviation"] + except KeyError: + home_abbr = home_team["team"]["name"][:3] + try: + away_abbr = away_team["team"]["abbreviation"] + except KeyError: + away_abbr = away_team["team"]["name"][:3] # Check if this is a favorite team game BEFORE doing expensive logging is_favorite_game = (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams) diff --git a/src/display_controller.py b/src/display_controller.py index 151499b6..b7777a88 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -31,6 +31,7 @@ from src.ncaa_fb_managers import NCAAFBLiveManager, NCAAFBRecentManager, NCAAFBU from src.ncaa_baseball_managers import NCAABaseballLiveManager, NCAABaseballRecentManager, NCAABaseballUpcomingManager from src.ncaam_basketball_managers import NCAAMBasketballLiveManager, NCAAMBasketballRecentManager, NCAAMBasketballUpcomingManager from src.ncaam_hockey_managers import NCAAMHockeyLiveManager, NCAAMHockeyRecentManager, NCAAMHockeyUpcomingManager +from src.ncaaw_hockey_managers import NCAAWHockeyLiveManager, NCAAWHockeyRecentManager, NCAAWHockeyUpcomingManager from src.youtube_display import YouTubeDisplay from src.calendar_manager import CalendarManager from src.text_display import TextDisplay @@ -255,6 +256,21 @@ class DisplayController: self.ncaam_hockey_recent = None self.ncaam_hockey_upcoming = None logger.info("NCAA Men's Hockey managers initialized in %.3f seconds", time.time() - ncaam_hockey_time) + + # Initialize NCAA Men's Hockey managers if enabled + ncaaw_hockey_time = time.time() + ncaaw_hockey_enabled = self.config.get('ncaaw_hockey_scoreboard', {}).get('enabled', False) + ncaaw_hockey_display_modes = self.config.get('ncaaw_hockey_scoreboard', {}).get('display_modes', {}) + + if ncaaw_hockey_enabled: + self.ncaaw_hockey_live = NCAAWHockeyLiveManager(self.config, self.display_manager, self.cache_manager) if ncaaw_hockey_display_modes.get('ncaaw_hockey_live', True) else None + self.ncaaw_hockey_recent = NCAAWHockeyRecentManager(self.config, self.display_manager, self.cache_manager) if ncaaw_hockey_display_modes.get('ncaaw_hockey_recent', True) else None + self.ncaaw_hockey_upcoming = NCAAWHockeyUpcomingManager(self.config, self.display_manager, self.cache_manager) if ncaaw_hockey_display_modes.get('ncaaw_hockey_upcoming', True) else None + else: + self.ncaaw_hockey_live = None + self.ncaaw_hockey_recent = None + self.ncaaw_hockey_upcoming = None + logger.info("NCAA Men's Hockey managers initialized in %.3f seconds", time.time() - ncaaw_hockey_time) # Track MLB rotation state self.mlb_current_team_index = 0 @@ -273,6 +289,7 @@ class DisplayController: 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.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) # List of available display modes (adjust order as desired) self.available_modes = [] @@ -322,6 +339,9 @@ class DisplayController: 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') + if ncaaw_hockey_enabled: + if self.ncaaw_hockey_recent: self.available_modes.append('ncaaw_hockey_recent') + if self.ncaaw_hockey_upcoming: self.available_modes.append('ncaaw_hockey_upcoming') # Add live modes to rotation if live_priority is False and there are live games self._update_live_modes_in_rotation() @@ -427,7 +447,10 @@ class DisplayController: 'ncaam_basketball_upcoming': 15, 'ncaam_hockey_live': 30, # Added NCAA Men's Hockey durations 'ncaam_hockey_recent': 15, - 'ncaam_hockey_upcoming': 15 + 'ncaam_hockey_upcoming': 15, + 'ncaaw_hockey_live': 30, # Added NCAA Men's Hockey durations + 'ncaaw_hockey_recent': 15, + 'ncaaw_hockey_upcoming': 15 } # Merge loaded durations with defaults for key, value in default_durations.items(): @@ -683,6 +706,10 @@ class DisplayController: 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() + elif current_sport == 'ncaaw_hockey': + if self.ncaaw_hockey_live: self.ncaaw_hockey_live.update() + if self.ncaaw_hockey_recent: self.ncaaw_hockey_recent.update() + if self.ncaaw_hockey_upcoming: self.ncaaw_hockey_upcoming.update() else: # If no specific sport is active, update all managers (fallback behavior) # This ensures data is available when switching to a sport @@ -726,6 +753,10 @@ class DisplayController: if self.ncaam_hockey_recent: self.ncaam_hockey_recent.update() if self.ncaam_hockey_upcoming: self.ncaam_hockey_upcoming.update() + if self.ncaaw_hockey_live: self.ncaaw_hockey_live.update() + if self.ncaaw_hockey_recent: self.ncaaw_hockey_recent.update() + if self.ncaaw_hockey_upcoming: self.ncaaw_hockey_upcoming.update() + def _check_live_games(self) -> tuple: """ Check if there are any live games available. @@ -755,6 +786,8 @@ class DisplayController: live_checks['ncaam_basketball'] = self.ncaam_basketball_live and self.ncaam_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): + live_checks['ncaaw_hockey'] = self.ncaaw_hockey_live and self.ncaaw_hockey_live.live_games for sport, has_live_games in live_checks.items(): if has_live_games: @@ -1006,6 +1039,7 @@ class DisplayController: ncaa_baseball_enabled = self.config.get('ncaa_baseball_scoreboard', {}).get('enabled', False) ncaam_basketball_enabled = self.config.get('ncaam_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) @@ -1017,6 +1051,7 @@ class DisplayController: 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('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) def run(self): """Run the display controller, switching between displays.""" @@ -1066,7 +1101,8 @@ 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), - ('ncaam_hockey', 'ncaam_hockey_live', self.ncaam_hockey_live_priority) + ('ncaam_hockey', 'ncaam_hockey_live', self.ncaam_hockey_live_priority), + ('ncaaw_hockey', 'ncaaw_hockey_live', self.ncaaw_hockey_live_priority) ]: manager = getattr(self, attr, None) # Only consider sports that are enabled (manager is not None) and have actual live games @@ -1276,6 +1312,12 @@ class DisplayController: manager_to_display = self.ncaam_hockey_recent elif self.current_display_mode == 'ncaam_hockey_upcoming' and self.ncaam_hockey_upcoming: manager_to_display = self.ncaam_hockey_upcoming + elif self.current_display_mode == 'ncaaw_hockey_live' and self.ncaaw_hockey_live: + manager_to_display = self.ncaaw_hockey_live + elif self.current_display_mode == 'ncaaw_hockey_recent' and self.ncaaw_hockey_recent: + manager_to_display = self.ncaaw_hockey_recent + elif self.current_display_mode == 'ncaaw_hockey_upcoming' and self.ncaaw_hockey_upcoming: + manager_to_display = self.ncaaw_hockey_upcoming elif self.current_display_mode == 'mlb_live' and self.mlb_live: manager_to_display = self.mlb_live elif self.current_display_mode == 'milb_live' and self.milb_live: @@ -1349,6 +1391,10 @@ class DisplayController: self.ncaam_hockey_recent.display(force_clear=self.force_clear) elif self.current_display_mode == 'ncaam_hockey_upcoming' and self.ncaam_hockey_upcoming: self.ncaam_hockey_upcoming.display(force_clear=self.force_clear) + elif self.current_display_mode == 'ncaaw_hockey_recent' and self.ncaaw_hockey_recent: + self.ncaaw_hockey_recent.display(force_clear=self.force_clear) + elif self.current_display_mode == 'ncaaw_hockey_upcoming' and self.ncaaw_hockey_upcoming: + self.ncaaw_hockey_upcoming.display(force_clear=self.force_clear) elif self.current_display_mode == 'milb_live' and self.milb_live and len(self.milb_live.live_games) > 0: logger.debug(f"[DisplayController] Calling MiLB live display with {len(self.milb_live.live_games)} live games") # Update data before displaying for live managers diff --git a/src/logo_downloader.py b/src/logo_downloader.py index 1756e9c6..218b3057 100644 --- a/src/logo_downloader.py +++ b/src/logo_downloader.py @@ -58,6 +58,7 @@ class LogoDownloader: 'ncaam_basketball': 'assets/sports/ncaa_logos', 'ncaa_baseball': 'assets/sports/ncaa_logos', 'ncaam_hockey': 'assets/sports/ncaa_logos', + 'ncaaw_hockey': 'assets/sports/ncaa_logos', # Soccer leagues - all use the same soccer_logos directory 'soccer_eng.1': 'assets/sports/soccer_logos', 'soccer_esp.1': 'assets/sports/soccer_logos', diff --git a/src/ncaaw_hockey_managers.py b/src/ncaaw_hockey_managers.py new file mode 100644 index 00000000..9ff82c09 --- /dev/null +++ b/src/ncaaw_hockey_managers.py @@ -0,0 +1,267 @@ +import logging +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional + +import pytz +import requests + +from src.base_classes.hockey import Hockey, HockeyLive +from src.base_classes.sports import SportsRecent, SportsUpcoming +from src.cache_manager import CacheManager # Keep CacheManager import +from src.display_manager import DisplayManager + +# Constants +ESPN_NCAAWH_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/hockey/womens-college-hockey/scoreboard" + + +class BaseNCAAWHockeyManager(Hockey): # Renamed class + """Base class for NCAA Womens Hockey managers with common functionality.""" # Updated docstring + + # Class variables for warning tracking + _no_data_warning_logged = False + _last_warning_time = 0 + _warning_cooldown = 60 # Only log warnings once per minute + _shared_data = None + _last_shared_update = 0 + _processed_games_cache = {} # Cache for processed game data + _processed_games_timestamp = 0 + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + self.logger = logging.getLogger("NCAAWH") # Changed logger name + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="ncaaw_hockey", + ) + + # Configuration is already set in base class + # self.logo_dir and self.update_interval are already configured + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("ncaaw_hockey_recent", False) + self.upcoming_enabled = display_modes.get("ncaaw_hockey_upcoming", False) + self.live_enabled = display_modes.get("ncaaw_hockey_live", False) + self.league = "womens-college-hockey" + + self.logger.info( + f"Initialized NCAAWHockey manager with display dimensions: {self.display_width}x{self.display_height}" + ) + self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info( + f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}" + ) + + def _fetch_ncaa_hockey_api_data(self, use_cache: bool = True) -> Optional[Dict]: + """ + Fetches the full season schedule for NCAAWH, caches it, and then filters + for relevant games based on the current configuration. + """ + now = datetime.now(pytz.utc) + season_year = now.year + if now.month < 8: + season_year = now.year - 1 + datestring = f"{season_year}0901-{season_year+1}0501" + cache_key = f"ncaa_womens_hockey_schedule_{season_year}" + + if use_cache: + cached_data = self.cache_manager.get(cache_key) + if cached_data: + # Validate cached data structure + if isinstance(cached_data, dict) and "events" in cached_data: + self.logger.info(f"Using cached schedule for {season_year}") + return cached_data + elif isinstance(cached_data, list): + # Handle old cache format (list of events) + self.logger.info( + f"Using cached schedule for {season_year} (legacy format)" + ) + return {"events": cached_data} + else: + self.logger.warning( + f"Invalid cached data format for {season_year}: {type(cached_data)}" + ) + # Clear invalid cache + self.cache_manager.clear_cache(cache_key) + + # If background service is disabled, fall back to synchronous fetch + if not self.background_enabled or not self.background_service: + return self._fetch_ncaa_api_data_sync(use_cache) + + self.logger.info( + f"Fetching full {season_year} season schedule from ESPN API..." + ) + + # Start background fetch + self.logger.info( + f"Starting background fetch for {season_year} season schedule..." + ) + + def fetch_callback(result): + """Callback when background fetch completes.""" + if result.success: + self.logger.info( + f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events" + ) + else: + self.logger.error( + f"Background fetch failed for {season_year}: {result.error}" + ) + + # Clean up request tracking + if season_year in self.background_fetch_requests: + del self.background_fetch_requests[season_year] + + # Get background service configuration + background_config = self.mode_config.get("background_service", {}) + timeout = background_config.get("request_timeout", 30) + max_retries = background_config.get("max_retries", 3) + priority = background_config.get("priority", 2) + + # Submit background fetch request + request_id = self.background_service.submit_fetch_request( + sport="ncaa_womens_hockey", + year=season_year, + url=ESPN_NCAAWH_SCOREBOARD_URL, + cache_key=cache_key, + params={"dates": datestring, "limit": 1000}, + headers=self.headers, + timeout=timeout, + max_retries=max_retries, + priority=priority, + callback=fetch_callback, + ) + + # Track the request + self.background_fetch_requests[season_year] = request_id + + # For immediate response, try to get partial data + partial_data = self._get_weeks_data() + if partial_data: + return partial_data + return None + + def _fetch_ncaa_api_data_sync(self, use_cache: bool = True) -> Optional[Dict]: + """ + Synchronous fallback for fetching NCAA Womens Hockey data when background service is disabled. + """ + now = datetime.now(pytz.utc) + current_year = now.year + cache_key = f"ncaa_womens_hockey_schedule_{current_year}" + + self.logger.info( + f"Fetching full {current_year} season schedule from ESPN API (sync mode)..." + ) + try: + response = self.session.get( + ESPN_NCAAWH_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, NCAAWHockeyLiveManager): + return self._fetch_todays_games() + else: + return self._fetch_ncaa_hockey_api_data(use_cache=True) + + +class NCAAWHockeyLiveManager(BaseNCAAWHockeyManager, HockeyLive): # Renamed class + """Manager for live NCAA Mens Hockey 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("NCAAWHockeyLiveManager") # Changed logger name + + # Initialize with test game only if test mode is enabled + if self.test_mode: + self.current_game = { + "id": "401596361", + "home_abbr": "RIT", + "away_abbr": "CLAR ", + "home_score": "3", + "away_score": "2", + "period": 2, + "period_text": "1st", + "home_id": "178", + "away_id": "2137", + "clock": "12:34", + "home_logo_path": Path(self.logo_dir, "RIT.png"), + "away_logo_path": Path(self.logo_dir, "CLAR .png"), + "game_time": "7:30 PM", + "game_date": "Apr 17", + "is_live": True, + "is_final": False, + "is_upcoming": False, + } + self.live_games = [self.current_game] + self.logger.info( + "Initialized NCAAWHockeyLiveManager with test game: RIT vs CLAR " + ) + else: + self.logger.info("Initialized NCAAWHockeyLiveManager in live mode") + + +class NCAAWHockeyRecentManager(BaseNCAAWHockeyManager, SportsRecent): + """Manager for recently completed NCAAWH 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( + "NCAAWHockeyRecentManager" + ) # Changed logger name + self.logger.info( + f"Initialized NCAAWHockeyRecentManager with {len(self.favorite_teams)} favorite teams" + ) + + +class NCAAWHockeyUpcomingManager(BaseNCAAWHockeyManager, SportsUpcoming): + """Manager for upcoming NCAA Womens Hockey 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( + "NCAAWHockeyUpcomingManager" + ) # Changed logger name + self.logger.info( + f"Initialized NCAAWHockeyUpcomingManager with {len(self.favorite_teams)} favorite teams" + )