Add NCAA Women's Hockey (#96)

* Add NCAA Womens Hockey

* Fix status text

---------

Co-authored-by: Alex Resnick <adr8282@gmail.com>
This commit is contained in:
Alex Resnick
2025-10-05 14:19:03 -05:00
committed by GitHub
parent 9ba76b56d7
commit 3406234e00
7 changed files with 351 additions and 5 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -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
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)

View File

@@ -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
@@ -256,6 +257,21 @@ class DisplayController:
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
self.mlb_showing_recent = True
@@ -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

View File

@@ -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',

View File

@@ -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"
)