diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..dd84ea78 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/assets/sports/ncaa_logos/AIC.png b/assets/sports/ncaa_logos/AIC.png new file mode 100644 index 00000000..74985a8a Binary files /dev/null and b/assets/sports/ncaa_logos/AIC.png differ diff --git a/assets/sports/ncaa_logos/BU.png b/assets/sports/ncaa_logos/BU.png new file mode 100644 index 00000000..8e129efe Binary files /dev/null and b/assets/sports/ncaa_logos/BU.png differ diff --git a/assets/sports/ncaa_logos/DAL.png b/assets/sports/ncaa_logos/DAL.png new file mode 100644 index 00000000..626be767 Binary files /dev/null and b/assets/sports/ncaa_logos/DAL.png differ diff --git a/assets/sports/ncaa_logos/DEN.png b/assets/sports/ncaa_logos/DEN.png new file mode 100644 index 00000000..2cb98f32 Binary files /dev/null and b/assets/sports/ncaa_logos/DEN.png differ diff --git a/assets/sports/ncaa_logos/ME.png b/assets/sports/ncaa_logos/ME.png new file mode 100644 index 00000000..362f5eb3 Binary files /dev/null and b/assets/sports/ncaa_logos/ME.png differ diff --git a/assets/sports/ncaa_logos/MSU.png b/assets/sports/ncaa_logos/MSU.png new file mode 100644 index 00000000..3778c210 Binary files /dev/null and b/assets/sports/ncaa_logos/MSU.png differ diff --git a/assets/sports/ncaa_logos/PU.png b/assets/sports/ncaa_logos/PU.png new file mode 100644 index 00000000..4892c384 Binary files /dev/null and b/assets/sports/ncaa_logos/PU.png differ diff --git a/assets/sports/ncaa_logos/RIT.png b/assets/sports/ncaa_logos/RIT.png new file mode 100644 index 00000000..87a0b9fb Binary files /dev/null and b/assets/sports/ncaa_logos/RIT.png differ diff --git a/assets/sports/ncaa_logos/SHU.png b/assets/sports/ncaa_logos/SHU.png new file mode 100644 index 00000000..d5dc58f1 Binary files /dev/null and b/assets/sports/ncaa_logos/SHU.png differ diff --git a/assets/sports/ncaa_logos/TB.png b/assets/sports/ncaa_logos/TB.png new file mode 100644 index 00000000..a3578dce Binary files /dev/null and b/assets/sports/ncaa_logos/TB.png differ diff --git a/assets/sports/ncaa_logos/UIW.png b/assets/sports/ncaa_logos/UIW.png new file mode 100644 index 00000000..8321eb7f Binary files /dev/null and b/assets/sports/ncaa_logos/UIW.png differ diff --git a/assets/sports/ncaa_logos/UTSA.png b/assets/sports/ncaa_logos/UTSA.png new file mode 100644 index 00000000..b81fc867 Binary files /dev/null and b/assets/sports/ncaa_logos/UTSA.png differ diff --git a/assets/sports/ncaa_logos/ncaah.png b/assets/sports/ncaa_logos/ncaah.png new file mode 100644 index 00000000..89919236 Binary files /dev/null and b/assets/sports/ncaa_logos/ncaah.png differ diff --git a/config/config.template.json b/config/config.template.json index b83a41cc..f2cb4e0d 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -180,6 +180,11 @@ "ncaam_basketball": { "enabled": false, "top_teams": 25 + }, + "ncaam_hockey": { + "enabled": true, + "top_teams": 10, + "show_ranking": true } }, "update_interval": 3600, @@ -354,6 +359,32 @@ "ncaam_basketball_upcoming": true } }, + "ncaam_hockey_scoreboard": { + "enabled": true, + "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, + "season_cache_duration_seconds": 86400, + "recent_games_to_show": 1, + "upcoming_games_to_show": 1, + "show_favorite_teams_only": true, + "favorite_teams": [ + "RIT" + ], + "logo_dir": "assets/sports/ncaa_logos", + "show_records": true, + "show_ranking": true, + "display_modes": { + "ncaam_hockey_live": true, + "ncaam_hockey_recent": true , + "ncaam_hockey_upcoming": true + } + }, "youtube": { "enabled": false, "update_interval": 3600 diff --git a/requirements-emulator.txt b/requirements-emulator.txt new file mode 100644 index 00000000..c7f50e88 --- /dev/null +++ b/requirements-emulator.txt @@ -0,0 +1 @@ +RGBMatrixEmulator \ No newline at end of file diff --git a/src/calendar_manager.py b/src/calendar_manager.py index 0cb33a56..35d4c798 100644 --- a/src/calendar_manager.py +++ b/src/calendar_manager.py @@ -9,7 +9,6 @@ from googleapiclient.discovery import build import pickle from PIL import Image, ImageDraw, ImageFont import numpy as np -from rgbmatrix import graphics import pytz from src.config_manager import ConfigManager import time diff --git a/src/display_controller.py b/src/display_controller.py index e0237211..227c9cb5 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -30,6 +30,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.ncaam_hockey_managers import NCAAMHockeyLiveManager, NCAAMHockeyRecentManager, NCAAMHockeyUpcomingManager from src.youtube_display import YouTubeDisplay from src.calendar_manager import CalendarManager from src.text_display import TextDisplay @@ -235,6 +236,21 @@ class DisplayController: self.ncaam_basketball_recent = None self.ncaam_basketball_upcoming = None logger.info("NCAA Men's Basketball managers initialized in %.3f seconds", time.time() - ncaam_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) + ncaam_hockey_display_modes = self.config.get('ncaam_hockey_scoreboard', {}).get('display_modes', {}) + + if ncaam_hockey_enabled: + self.ncaam_hockey_live = NCAAMHockeyLiveManager(self.config, self.display_manager, self.cache_manager) if ncaam_hockey_display_modes.get('ncaam_hockey_live', True) else None + self.ncaam_hockey_recent = NCAAMHockeyRecentManager(self.config, self.display_manager, self.cache_manager) if ncaam_hockey_display_modes.get('ncaam_hockey_recent', True) else None + self.ncaam_hockey_upcoming = NCAAMHockeyUpcomingManager(self.config, self.display_manager, self.cache_manager) if ncaam_hockey_display_modes.get('ncaam_hockey_upcoming', True) else None + else: + self.ncaam_hockey_live = None + 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) # Track MLB rotation state self.mlb_current_team_index = 0 @@ -252,6 +268,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.ncaam_hockey_live_priority = self.config.get('ncaam_hockey_scoreboard', {}).get('live_priority', True) # List of available display modes (adjust order as desired) self.available_modes = [] @@ -297,6 +314,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 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') # Add live modes to rotation if live_priority is False and there are live games self._update_live_modes_in_rotation() @@ -399,7 +419,10 @@ class DisplayController: 'ncaa_baseball_upcoming': 15, 'ncaam_basketball_live': 30, # Added NCAA Men's Basketball durations 'ncaam_basketball_recent': 15, - 'ncaam_basketball_upcoming': 15 + 'ncaam_basketball_upcoming': 15, + 'ncaam_hockey_live': 30, # Added NCAA Men's Hockey durations + 'ncaam_hockey_recent': 15, + 'ncaam_hockey_upcoming': 15 } # Merge loaded durations with defaults for key, value in default_durations.items(): @@ -627,6 +650,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 == 'ncaam_hockey': + 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() else: # If no specific sport is active, update all managers (fallback behavior) # This ensures data is available when switching to a sport @@ -666,6 +693,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.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() + def _check_live_games(self) -> tuple: """ Check if there are any live games available. @@ -693,6 +724,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 '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 for sport, has_live_games in live_checks.items(): if has_live_games: @@ -943,6 +976,7 @@ 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) + ncaam_hockey_enabled = self.config.get('ncaam_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) @@ -953,6 +987,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('ncaam_hockey_live', getattr(self, 'ncaam_hockey_live', None), self.ncaam_hockey_live_priority, ncaam_hockey_enabled) def run(self): """Run the display controller, switching between displays.""" @@ -995,7 +1030,8 @@ class DisplayController: ('nfl', 'nfl_live', self.nfl_live_priority), ('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_basketball', 'ncaam_basketball_live', self.ncaam_basketball_live_priority), + ('ncaam_hockey', 'ncaam_hockey_live', self.ncaam_hockey_live_priority) ]: manager = getattr(self, attr, None) # Only consider sports that are enabled (manager is not None) and have actual live games @@ -1196,6 +1232,12 @@ 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 == '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: + 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 == 'mlb_live' and self.mlb_live: manager_to_display = self.mlb_live elif self.current_display_mode == 'milb_live' and self.milb_live: @@ -1260,6 +1302,10 @@ class DisplayController: self.ncaa_baseball_recent.display(force_clear=self.force_clear) elif self.current_display_mode == 'ncaa_baseball_upcoming' and self.ncaa_baseball_upcoming: self.ncaa_baseball_upcoming.display(force_clear=self.force_clear) + elif self.current_display_mode == 'ncaam_hockey_recent' and self.ncaam_hockey_recent: + 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 == '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/display_manager.py b/src/display_manager.py index 84c515a0..4ee9ad72 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -1,4 +1,8 @@ -from rgbmatrix import RGBMatrix, RGBMatrixOptions +import os +if os.getenv("EMULATOR", "false") == "true": + from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions +else: + from rgbmatrix import RGBMatrix, RGBMatrixOptions from PIL import Image, ImageDraw, ImageFont import time from typing import Dict, Any, List, Tuple diff --git a/src/leaderboard_manager.py b/src/leaderboard_manager.py index 0639f367..ebcaa62f 100644 --- a/src/leaderboard_manager.py +++ b/src/leaderboard_manager.py @@ -1,22 +1,17 @@ import time import logging import requests -import json from typing import Dict, Any, List, Optional -from datetime import datetime, timedelta, timezone import os from PIL import Image, ImageDraw, ImageFont -import pytz try: from .display_manager import DisplayManager from .cache_manager import CacheManager - from .config_manager import ConfigManager from .logo_downloader import download_missing_logo except ImportError: # Fallback for direct imports from display_manager import DisplayManager from cache_manager import CacheManager - from config_manager import ConfigManager from logo_downloader import download_missing_logo # Import the API counter function from web interface @@ -149,7 +144,16 @@ class LeaderboardManager: 'season': self.enabled_sports.get('ncaa_baseball', {}).get('season', 2025), 'level': self.enabled_sports.get('ncaa_baseball', {}).get('level', 1), 'sort': self.enabled_sports.get('ncaa_baseball', {}).get('sort', 'winpercent:desc,gamesbehind:asc') - } + }, + 'ncaam_hockey': { + 'sport': 'hockey', + 'league': 'mens-college-hockey', + 'logo_dir': 'assets/sports/ncaa_logos', + 'league_logo': 'assets/sports/ncaa_logos/ncaah.png', + 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-hockey/teams', + 'enabled': self.enabled_sports.get('ncaam_hockey', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('ncaam_hockey', {}).get('top_teams', 25) + }, } logger.info(f"LeaderboardManager initialized with enabled sports: {[k for k, v in self.league_configs.items() if v['enabled']]}") @@ -290,6 +294,9 @@ class LeaderboardManager: if league_key == 'college-football': return self._fetch_ncaa_fb_rankings(league_config) + if league_key == 'mens-college-hockey': + return self._fetch_ncaam_hockey_rankings(league_config) + # Use standings endpoint for NFL, MLB, NHL, and NCAA Baseball if league_key in ['nfl', 'mlb', 'nhl', 'college-baseball']: return self._fetch_standings_data(league_config) @@ -472,6 +479,111 @@ class LeaderboardManager: logger.error(f"Error fetching rankings for {league_key}: {e}") return [] + def _fetch_ncaam_hockey_rankings(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]]: + """Fetch NCAA Hockey rankings from ESPN API using the rankings endpoint.""" + league_key = league_config['league'] + cache_key = f"leaderboard_{league_key}_rankings" + + # Try to get cached data first + cached_data = self.cache_manager.get_cached_data_with_strategy(cache_key, 'leaderboard') + if cached_data: + logger.info(f"Using cached rankings data for {league_key}") + return cached_data.get('standings', []) + + try: + logger.info(f"Fetching fresh rankings data for {league_key}") + rankings_url = "https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/rankings" + + # Get rankings data + response = requests.get(rankings_url, timeout=self.request_timeout) + response.raise_for_status() + data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) + + logger.info(f"Available rankings: {[rank['name'] for rank in data.get('availableRankings', [])]}") + logger.info(f"Latest season: {data.get('latestSeason', {})}") + logger.info(f"Latest week: {data.get('latestWeek', {})}") + + rankings_data = data.get('rankings', []) + if not rankings_data: + logger.warning("No rankings data found") + return [] + + # Use the first ranking (usually AP Top 25) + first_ranking = rankings_data[0] + ranking_name = first_ranking.get('name', 'Unknown') + ranking_type = first_ranking.get('type', 'Unknown') + teams = first_ranking.get('ranks', []) + + logger.info(f"Using ranking: {ranking_name} ({ranking_type})") + logger.info(f"Found {len(teams)} teams in ranking") + + standings = [] + + # Process each team in the ranking + for team_data in teams: + team_info = team_data.get('team', {}) + team_name = team_info.get('name', 'Unknown') + team_abbr = team_info.get('abbreviation', 'Unknown') + current_rank = team_data.get('current', 0) + record_summary = team_data.get('recordSummary', '0-0') + + logger.debug(f" {current_rank}. {team_name} ({team_abbr}): {record_summary}") + + # Parse the record string (e.g., "12-1", "8-4", "10-2-1") + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0 + + try: + parts = record_summary.split('-') + if len(parts) >= 2: + wins = int(parts[0]) + losses = int(parts[1]) + if len(parts) == 3: + ties = int(parts[2]) + + # Calculate win percentage + total_games = wins + losses + ties + win_percentage = wins / total_games if total_games > 0 else 0 + except (ValueError, IndexError): + logger.warning(f"Could not parse record for {team_name}: {record_summary}") + continue + + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'rank': current_rank, + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage, + 'record_summary': record_summary, + 'ranking_name': ranking_name + }) + + # Limit to top teams (they're already ranked) + top_teams = standings[:league_config['top_teams']] + + # Cache the results + cache_data = { + 'standings': top_teams, + 'timestamp': time.time(), + 'league': league_key, + 'ranking_name': ranking_name + } + self.cache_manager.save_cache(cache_key, cache_data) + + logger.info(f"Fetched and cached {len(top_teams)} teams for {league_key} using {ranking_name}") + return top_teams + + except Exception as e: + logger.error(f"Error fetching rankings for {league_key}: {e}") + return [] + def _fetch_standings_data(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]]: """Fetch standings data from ESPN API using the standings endpoint.""" league_key = league_config['league'] diff --git a/src/logo_downloader.py b/src/logo_downloader.py index 953961c8..b2b41583 100644 --- a/src/logo_downloader.py +++ b/src/logo_downloader.py @@ -32,6 +32,7 @@ class LogoDownloader: 'fcs': 'https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams', # FCS teams from same endpoint 'ncaam_basketball': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/teams', 'ncaa_baseball': 'https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/teams', + 'ncaam_hockey': 'https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/teams', # Soccer leagues 'soccer_eng.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/eng.1/teams', 'soccer_esp.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/esp.1/teams', @@ -55,6 +56,7 @@ class LogoDownloader: 'fcs': 'assets/sports/ncaa_logos', # FCS teams go in same directory 'ncaam_basketball': 'assets/sports/ncaa_logos', 'ncaa_baseball': 'assets/sports/ncaa_logos', + 'ncaam_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', @@ -181,7 +183,7 @@ class LogoDownloader: try: logger.info(f"Fetching team data for {league} from ESPN API...") - response = self.session.get(api_url, headers=self.headers, timeout=self.request_timeout) + response = self.session.get(api_url, params={'limit':1000},headers=self.headers, timeout=self.request_timeout) response.raise_for_status() data = response.json() diff --git a/src/ncaa_fb_managers.py b/src/ncaa_fb_managers.py index 7a0cb30a..9baac1e8 100644 --- a/src/ncaa_fb_managers.py +++ b/src/ncaa_fb_managers.py @@ -103,6 +103,8 @@ class BaseNCAAFBManager: # Renamed class self._rankings_cache_timestamp = 0 self._rankings_cache_duration = 3600 # Cache rankings for 1 hour + self.top_25_rankings = [] + self.logger.info(f"Initialized NCAAFB 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}") @@ -190,7 +192,7 @@ class BaseNCAAFBManager: # Renamed class odds_data = self.odds_manager.get_odds( sport="football", - league="ncaa_fb", + league="college-football", event_id=game['id'], update_interval_seconds=update_interval ) @@ -367,6 +369,39 @@ class BaseNCAAFBManager: # Renamed class else: return self._fetch_ncaa_fb_api_data(use_cache=True) + def _fetch_rankings(self): + self.logger.info(f"[NCAAFB] Fetching current AP Top 25 rankings from ESPN API...") + try: + url = "http://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings" + + response = requests.get(url) + response.raise_for_status() + data = response.json() + + # Grab rankings[0] + rankings_0 = data.get("rankings", [])[0] + + # Extract top 25 team abbreviations + self.top_25_rankings = [ + entry["team"]["abbreviation"] + for entry in rankings_0.get("ranks", [])[:25] + ] + + except requests.exceptions.RequestException as e: + self.logger.error(f"[NCAAFB] Error retrieving AP Top 25 rankings: {e}") + + def _get_rank(self, team_to_check): + i = 1 + if self.top_25_rankings: + for team in self.top_25_rankings: + if team == team_to_check: + return i + i += 1 + else: + return 0 + else: + return 0 + def _load_fonts(self): """Load fonts used by the scoreboard.""" fonts = {} @@ -376,6 +411,7 @@ class BaseNCAAFBManager: # Renamed class fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Using 4x6 for status fonts['detail'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Added detail font + fonts['rank'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) logging.info("[NCAAFB] Successfully loaded fonts") # Changed log prefix except IOError: logging.warning("[NCAAFB] Fonts not found, using default PIL font.") # Changed log prefix @@ -384,6 +420,7 @@ class BaseNCAAFBManager: # Renamed class fonts['team'] = ImageFont.load_default() fonts['status'] = ImageFont.load_default() fonts['detail'] = ImageFont.load_default() + fonts['rank'] = ImageFont.load_default() return fonts def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None: @@ -833,6 +870,9 @@ class NCAAFBLiveManager(BaseNCAAFBManager): # Renamed class self.logger.warning("[NCAAFB] Test mode: Could not parse clock") # Changed log prefix # No actual display call here, let main loop handle it else: + # Fetch rankings + self._fetch_rankings() + # Fetch live game data data = self._fetch_data() new_live_games = [] @@ -960,6 +1000,24 @@ class NCAAFBLiveManager(BaseNCAAFBManager): # Renamed class main_img.paste(away_logo, (away_x, away_y), away_logo) # --- Draw Text Elements on Overlay --- + # Ranking (if ranked) + home_rank = self._get_rank(game["home_abbr"]) + away_rank = self._get_rank(game["away_abbr"]) + + if home_rank > 0: + rank_text = str(home_rank) + rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank']) + rank_x = home_x - 8 + rank_y = 2 + self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank']) + + if away_rank > 0: + rank_text = str(away_rank) + rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank']) + rank_x = away_x + away_logo.width + 8 + rank_y = 2 + self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank']) + # Scores (centered, slightly above bottom) home_score = str(game.get("home_score", "0")) away_score = str(game.get("away_score", "0")) @@ -1103,6 +1161,9 @@ class NCAAFBRecentManager(BaseNCAAFBManager): # Renamed class self.last_update = current_time # Update time even if fetch fails try: + # Fetch rankings + self._fetch_rankings() + data = self._fetch_data() # Uses shared cache if not data or 'events' not in data: self.logger.warning("[NCAAFB Recent] No events found in shared data.") # Changed log prefix @@ -1236,6 +1297,24 @@ class NCAAFBRecentManager(BaseNCAAFBManager): # Renamed class main_img.paste(away_logo, (away_x, away_y), away_logo) # Draw Text Elements on Overlay + # Ranking (if ranked) + home_rank = self._get_rank(game["home_abbr"]) + away_rank = self._get_rank(game["away_abbr"]) + + if home_rank > 0: + rank_text = str(home_rank) + rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank']) + rank_x = home_x - 8 + rank_y = 2 + self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank']) + + if away_rank > 0: + rank_text = str(away_rank) + rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank']) + rank_x = away_x + away_logo.width - 8 + rank_y = 2 + self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank']) + # Final Scores (Centered, same position as live) home_score = str(game.get("home_score", "0")) away_score = str(game.get("away_score", "0")) @@ -1400,6 +1479,9 @@ class NCAAFBUpcomingManager(BaseNCAAFBManager): # Renamed class self.last_update = current_time try: + # Fetch rankings + self._fetch_rankings() + data = self._fetch_data() # Uses shared cache if not data or 'events' not in data: self.logger.warning("[NCAAFB Upcoming] No events found in shared data.") # Changed log prefix @@ -1587,6 +1669,25 @@ class NCAAFBUpcomingManager(BaseNCAAFBManager): # Renamed class game_date = game.get("game_date", "") game_time = game.get("game_time", "") + # Ranking (if ranked) + home_rank = self._get_rank(game["home_abbr"]) + away_rank = self._get_rank(game["away_abbr"]) + + if home_rank > 0: + rank_text = str(home_rank) + rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank']) + rank_x = home_x - 8 + rank_y = 2 + self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank']) + + if away_rank > 0: + rank_text = str(away_rank) + rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank']) + rank_x = away_x + away_logo.width - 8 + rank_y = 2 + self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank']) + + # "Next Game" at the top (use smaller status font) status_text = "Next Game" status_width = draw_overlay.textlength(status_text, font=self.fonts['status']) diff --git a/src/ncaam_hockey_managers.py b/src/ncaam_hockey_managers.py new file mode 100644 index 00000000..9e1b93c7 --- /dev/null +++ b/src/ncaam_hockey_managers.py @@ -0,0 +1,954 @@ +import os +import time +import logging +import requests +import json +from typing import Dict, Any, Optional +from PIL import Image, ImageDraw, ImageFont +from datetime import datetime, timezone +from src.display_manager import DisplayManager +from src.cache_manager import CacheManager # Keep CacheManager import +from src.odds_manager import OddsManager +from src.logo_downloader import download_missing_logo +import pytz +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +# Constants +ESPN_NCAAMH_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/scoreboard" # Changed URL for NCAA FB + +# 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 BaseNCAAMHockeyManager: # Renamed class + """Base class for NCAA Mens 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 + logger = logging.getLogger('NCAAMH') # Changed logger name + + def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): + self.display_manager = display_manager + self.config = config + self.cache_manager = cache_manager + self.config_manager = self.cache_manager.config_manager + self.odds_manager = OddsManager(self.cache_manager, self.config_manager) + self.ncaam_hockey_config = config.get("ncaam_hockey_scoreboard", {}) # Changed config key + self.is_enabled = self.ncaam_hockey_config.get("enabled", False) + self.show_odds = self.ncaam_hockey_config.get("show_odds", False) + self.test_mode = self.ncaam_hockey_config.get("test_mode", False) + self.logo_dir = self.ncaam_hockey_config.get("logo_dir", "assets/sports/ncaa_logos") # Changed logo dir + self.update_interval = self.ncaam_hockey_config.get("update_interval_seconds", 60) + self.show_records = self.ncaam_hockey_config.get('show_records', False) + self.show_ranking = self.ncaam_hockey_config.get('show_ranking', False) + self.season_cache_duration = self.ncaam_hockey_config.get("season_cache_duration_seconds", 86400) # 24 hours default + # Number of games to show (instead of time-based windows) + self.recent_games_to_show = self.ncaam_hockey_config.get("recent_games_to_show", 5) # Show last 5 games + self.upcoming_games_to_show = self.ncaam_hockey_config.get("upcoming_games_to_show", 10) # Show next 10 games + + # Set up session with retry logic + self.session = requests.Session() + retry_strategy = Retry( + total=5, # increased number of retries + backoff_factor=1, # increased backoff factor + status_forcelist=[429, 500, 502, 503, 504], # added 429 to retry list + allowed_methods=["GET", "HEAD", "OPTIONS"] + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("https://", adapter) + self.session.mount("http://", adapter) + + # Set up headers + self.headers = { + 'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)', + 'Accept': 'application/json', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive' + } + self.last_update = 0 + self.current_game = None + self.fonts = self._load_fonts() + self.favorite_teams = self.ncaam_hockey_config.get("favorite_teams", []) + + # Check display modes to determine what data to fetch + display_modes = self.ncaam_hockey_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("ncaam_hockey_recent", False) + self.upcoming_enabled = display_modes.get("ncaam_hockey_upcoming", False) + self.live_enabled = display_modes.get("ncaam_hockey_live", False) + + self.logger.setLevel(logging.INFO) + + self.display_width = self.display_manager.matrix.width + self.display_height = self.display_manager.matrix.height + + self._logo_cache = {} + + # Initialize team rankings cache + self._team_rankings_cache = {} + self._rankings_cache_timestamp = 0 + self._rankings_cache_duration = 3600 # Cache rankings for 1 hour + + self.logger.info(f"Initialized NCAAMHockey manager with display dimensions: {self.display_width}x{self.display_height}") + self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}") + + def _fetch_team_rankings(self) -> Dict[str, int]: + """Fetch current team rankings from ESPN API.""" + current_time = time.time() + + # Check if we have cached rankings that are still valid + if (self._team_rankings_cache and + current_time - self._rankings_cache_timestamp < self._rankings_cache_duration): + return self._team_rankings_cache + + try: + rankings_url = "https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/rankings" + response = self.session.get(rankings_url, headers=self.headers, timeout=30) + response.raise_for_status() + data = response.json() + + rankings = {} + rankings_data = data.get('rankings', []) + + if rankings_data: + # Use the first ranking (usually AP Top 25) + first_ranking = rankings_data[0] + teams = first_ranking.get('ranks', []) + + for team_data in teams: + team_info = team_data.get('team', {}) + team_abbr = team_info.get('abbreviation', '') + current_rank = team_data.get('current', 0) + + if team_abbr and current_rank > 0: + rankings[team_abbr] = current_rank + + # Cache the results + self._team_rankings_cache = rankings + self._rankings_cache_timestamp = current_time + + self.logger.debug(f"Fetched rankings for {len(rankings)} teams") + return rankings + + except Exception as e: + self.logger.error(f"Error fetching team rankings: {e}") + return {} + + def _get_timezone(self): + try: + timezone_str = self.config.get('timezone', 'UTC') + return pytz.timezone(timezone_str) + except pytz.UnknownTimeZoneError: + return pytz.utc + + def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: + """Check if we should log a warning based on cooldown period.""" + current_time = time.time() + if current_time - self._last_warning_time > cooldown: + self._last_warning_time = current_time + return True + return False + + def _fetch_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.ncaam_hockey_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.ncaam_hockey_config.get("live_odds_update_interval", 60) if is_live \ + else self.ncaam_hockey_config.get("odds_update_interval", 3600) + + odds_data = self.odds_manager.get_odds( + sport="hockey", + league="mens-college-hockey", + 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 _fetch_ncaa_fb_api_data(self, use_cache: bool = True) -> Optional[Dict]: + """ + Fetches the full season schedule for NCAAMH, caches it, and then filters + for relevant games based on the current configuration. + """ + now = datetime.now(pytz.utc) + current_year = now.year + years_to_check = [current_year] + if now.month < 8: + years_to_check.append(current_year - 1) + + all_events = [] + for year in years_to_check: + cache_key = f"ncaamh_schedule_{year}" + if use_cache: + cached_data = self.cache_manager.get(cache_key, max_age=self.season_cache_duration) + if cached_data: + self.logger.info(f"[NCAAMH] Using cached schedule for {year}") + all_events.extend(cached_data) + continue + + self.logger.info(f"[NCAAMH] Fetching full {year} season schedule from ESPN API...") + try: + response = self.session.get(ESPN_NCAAMH_SCOREBOARD_URL, params={"dates": year,"limit":1000},headers=self.headers, timeout=15) + response.raise_for_status() + data = response.json() + events = data.get('events', []) + if use_cache: + self.cache_manager.set(cache_key, events) + self.logger.info(f"[NCAAMH] Successfully fetched and cached {len(events)} events for {year} season.") + all_events.extend(events) + except requests.exceptions.RequestException as e: + self.logger.error(f"[NCAAMH] API error fetching full schedule for {year}: {e}") + continue + + if not all_events: + self.logger.warning("[NCAAMH] No events found in schedule data.") + return None + + return {'events': all_events} + + def _fetch_data(self, date_str: str = None) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, NCAAMHockeyLiveManager): + return self._fetch_ncaa_fb_api_data(use_cache=False) + else: + return self._fetch_ncaa_fb_api_data(use_cache=True) + + def _load_fonts(self): + """Load fonts used by the scoreboard.""" + fonts = {} + try: + fonts['score'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) + fonts['time'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) + fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) + fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + logging.info("[NCAAMH] Successfully loaded Press Start 2P font for all text elements") + except IOError: + logging.warning("[NCAAMH] 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("[NCAAMH] Successfully loaded 4x6 font for all text elements") + except IOError: + logging.warning("[NCAAMH] 4x6 font not found, using default PIL font.") + # Use default PIL font as a last resort + fonts['score'] = ImageFont.load_default() + fonts['time'] = ImageFont.load_default() + fonts['team'] = ImageFont.load_default() + fonts['status'] = ImageFont.load_default() + return fonts + + def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): + """ + Draw text with a black outline for better readability. + + Args: + draw: ImageDraw object + text: Text to draw + position: (x, y) position to draw the text + font: Font to use + fill: Text color (default: white) + outline_color: Outline color (default: black) + """ + x, y = position + + # Draw the outline by drawing the text in black at 8 positions around the text + for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: + draw.text((x + dx, y + dy), text, font=font, fill=outline_color) + + # Draw the text in the specified color + draw.text((x, y), text, font=font, fill=fill) + + def _load_and_resize_logo(self, team_abbrev: str, team_name: str = None) -> Optional[Image.Image]: + """Load and resize a team logo, with caching and automatic download if missing.""" + if team_abbrev in self._logo_cache: + 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: + # Try to download missing logo first + if not os.path.exists(logo_path): + self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.") + + # Try to download the logo from ESPN API + success = download_missing_logo(team_abbrev, 'ncaam_hockey', team_name) + + if not success: + # Create placeholder if download fails + self.logger.warning(f"Failed to download logo for {team_abbrev}. Creating placeholder.") + os.makedirs(os.path.dirname(logo_path), exist_ok=True) + logo = Image.new('RGBA', (32, 32), (200, 200, 200, 255)) # Gray placeholder + draw = ImageDraw.Draw(logo) + draw.text((2, 10), team_abbrev, fill=(0, 0, 0, 255)) + logo.save(logo_path) + self.logger.info(f"Created placeholder logo at {logo_path}") + + logo = Image.open(logo_path) + if logo.mode != 'RGBA': + logo = logo.convert('RGBA') + + max_width = int(self.display_width * 1.5) + max_height = int(self.display_height * 1.5) + logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + self._logo_cache[team_abbrev] = logo + return logo + + except Exception as e: + self.logger.error(f"Error loading logo for {team_abbrev}: {e}", exc_info=True) + return None + + def _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"[NCAAMH] Parsed game time: {start_time_utc}") + except ValueError: + logging.warning(f"[NCAAMH] Could not parse game date: {game_date_str}") + start_time_utc = None + + home_team = next(c for c in competitors if c.get("homeAway") == "home") + away_team = next(c for c in competitors if c.get("homeAway") == "away") + home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else '' + away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else '' + + # Don't show "0-0" records - set to blank instead + if home_record == "0-0": + home_record = '' + if away_record == "0-0": + away_record = '' + + # Format game time and date for display + game_time = "" + game_date = "" + if start_time_utc: + # Convert to local time + local_time = start_time_utc.astimezone(self._get_timezone()) + game_time = local_time.strftime("%-I:%M%p") + + # Check date format from config + use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False) + if use_short_date_format: + game_date = local_time.strftime("%-m/%-d") + else: + game_date = self.display_manager.format_date_with_ordinal(local_time) + + details = { + "start_time_utc": start_time_utc, + "status_text": status["type"]["shortDetail"], + "period": status.get("period", 0), + "clock": status.get("displayClock", "0:00"), + "is_live": status["type"]["state"] in ("in", "halftime"), + "is_final": status["type"]["state"] == "post", + "is_upcoming": status["type"]["state"] == "pre", + "home_abbr": home_team["team"]["abbreviation"], + "home_score": home_team.get("score", "0"), + "home_record": home_record, + "home_logo_path": os.path.join(self.logo_dir, f"{home_team['team']['abbreviation']}.png"), + "away_abbr": away_team["team"]["abbreviation"], + "away_score": away_team.get("score", "0"), + "away_record": away_record, + "away_logo_path": os.path.join(self.logo_dir, f"{away_team['team']['abbreviation']}.png"), + "game_time": game_time, + "game_date": game_date, + "id": game_event.get("id") + } + + # Log game details for debugging + self.logger.debug(f"[NCAAMH] Extracted game details: {details['away_abbr']} vs {details['home_abbr']}") + # Use .get() to avoid KeyError if optional keys are missing + self.logger.debug( + f"[NCAAMH] Game status: is_final={details.get('is_final')}, " + f"is_upcoming={details.get('is_upcoming')}, is_live={details.get('is_live')}" + ) + + # Validate logo files + for team in ["home", "away"]: + logo_path = details[f"{team}_logo_path"] + if not os.path.isfile(logo_path): + # logging.warning(f"[NCAAMH] {team.title()} logo not found: {logo_path}") + details[f"{team}_logo_path"] = None + else: + try: + with Image.open(logo_path) as img: + logging.debug(f"[NCAAMH] {team.title()} logo is valid: {img.format}, size: {img.size}") + except Exception as e: + logging.error(f"[NCAAMH] {team.title()} logo file exists but is not valid: {e}") + details[f"{team}_logo_path"] = None + + return details + except Exception as e: + logging.error(f"[NCAAMH] Error extracting game details: {e}") + return None + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the scorebug layout for the current game.""" + try: + # Create a new black image for the main display + main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) + + # Load logos once + home_logo = self._load_and_resize_logo(game["home_abbr"]) + away_logo = self._load_and_resize_logo(game["away_abbr"]) + + if not home_logo or not away_logo: + self.logger.error("Failed to load one or both team logos") + return + + # Create a single overlay for both logos + overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) + + # Calculate vertical center line for alignment + center_y = self.display_height // 2 + + # Draw home team logo (far right, extending beyond screen) + home_x = self.display_width - home_logo.width + 2 + home_y = center_y - (home_logo.height // 2) + + # Paste the home logo onto the overlay + overlay.paste(home_logo, (home_x, home_y), home_logo) + + # Draw away team logo (far left, extending beyond screen) + away_x = -2 + away_y = center_y - (away_logo.height // 2) + + # Paste the away logo onto the overlay + overlay.paste(away_logo, (away_x, away_y), away_logo) + + # Composite the overlay with the main image + main_img = Image.alpha_composite(main_img, overlay) + + # Convert to RGB for final display + main_img = main_img.convert('RGB') + draw = ImageDraw.Draw(main_img) + + # Check if this is an upcoming game + is_upcoming = game.get("is_upcoming", False) + + if is_upcoming: + # For upcoming games, show date and time stacked in the center + game_date = game.get("game_date", "") + game_time = game.get("game_time", "") + + # Show "Next Game" at the top + status_text = "Next Game" + status_width = draw.textlength(status_text, font=self.fonts['status']) + status_x = (self.display_width - status_width) // 2 + status_y = 2 + self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['status']) + + # Calculate position for the date text (centered horizontally, below "Next Game") + date_width = draw.textlength(game_date, font=self.fonts['time']) + date_x = (self.display_width - date_width) // 2 + date_y = center_y - 5 # Position in center + self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['time']) + + # Calculate position for the time text (centered horizontally, in center) + time_width = draw.textlength(game_time, font=self.fonts['time']) + time_x = (self.display_width - time_width) // 2 + time_y = date_y + 10 # Position below date + self._draw_text_with_outline(draw, game_time, (time_x, time_y), self.fonts['time']) + else: + # For live/final games, show scores and period/time + home_score = str(game.get("home_score", "0")) + away_score = str(game.get("away_score", "0")) + score_text = f"{away_score}-{home_score}" + + # Calculate position for the score text (centered at the bottom) + score_width = draw.textlength(score_text, font=self.fonts['score']) + score_x = (self.display_width - score_width) // 2 + score_y = self.display_height - 15 + self._draw_text_with_outline(draw, score_text, (score_x, score_y), self.fonts['score']) + + # Draw period and time or Final + if game.get("is_final", False): + status_text = "Final" + else: + period = game.get("period", 0) + clock = game.get("clock", "0:00") + + # Format period text + if period > 3: + period_text = "OT" + else: + period_text = f"{period}{'st' if period == 1 else 'nd' if period == 2 else 'rd'}" + + status_text = f"{period_text} {clock}" + + # Calculate position for the status text (centered at the top) + status_width = draw.textlength(status_text, font=self.fonts['time']) + status_x = (self.display_width - status_width) // 2 + status_y = 5 + self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['time']) + + # Display odds if available + if 'odds' in game: + odds = game['odds'] + spread = odds.get('spread', {}).get('point', None) + if spread is not None: + # Format spread text + spread_text = f"{spread:+.1f}" if spread > 0 else f"{spread:.1f}" + + # Choose color and position based on which team has the spread + if odds.get('spread', {}).get('team') == game['home_abbr']: + text_color = (255, 100, 100) # Reddish + spread_x = self.display_width - draw.textlength(spread_text, font=self.fonts['status']) - 2 + else: + text_color = (100, 255, 100) # Greenish + spread_x = 2 + + spread_y = 0 + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), self.fonts['status'], fill=text_color) + + # Draw records if enabled + if self.show_records: + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + except IOError: + record_font = ImageFont.load_default() + + away_record = game.get('away_record', '') + home_record = game.get('home_record', '') + + record_bbox = draw.textbbox((0,0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height + + if away_record: + away_record_x = 2 + self._draw_text_with_outline(draw, away_record, (away_record_x, record_y), record_font) + + if home_record: + home_record_bbox = draw.textbbox((0,0), home_record, font=record_font) + home_record_width = home_record_bbox[2] - home_record_bbox[0] + home_record_x = self.display_width - home_record_width - 2 + self._draw_text_with_outline(draw, home_record, (home_record_x, record_y), record_font) + + # Display the image + self.display_manager.image.paste(main_img, (0, 0)) + self.display_manager.update_display() + + except Exception as e: + self.logger.error(f"Error displaying game: {e}", exc_info=True) + + def display(self, force_clear: bool = False) -> None: + """Common display method for all NCAAMH 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("[NCAAMH] No game data available to display") + self._last_warning_time = current_time + return + + self._draw_scorebug_layout(self.current_game, force_clear) + +class NCAAMHockeyLiveManager(BaseNCAAMHockeyManager): # 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.update_interval = self.ncaam_hockey_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 NCAA Mens Hockey 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_hockey_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 + + # Initialize with test game only if test mode is enabled + if self.test_mode: + self.current_game = { + "home_abbr": "RIT", + "away_abbr": "PU", + "home_score": "3", + "away_score": "2", + "period": 2, + "clock": "12:34", + "home_logo_path": os.path.join(self.logo_dir, "RIT.png"), + "away_logo_path": os.path.join(self.logo_dir, "PU.png"), + "game_time": "7:30 PM", + "game_date": "Apr 17" + } + self.live_games = [self.current_game] + logging.info("[NCAAMH] Initialized NCAAMHockeyLiveManager with test game: RIT vs PU") + else: + logging.info("[NCAAMH] Initialized NCAAMHockeyLiveManager in live mode") + + def update(self): + """Update live game data.""" + if not self.is_enabled: return + current_time = time.time() + interval = self.no_data_interval if not self.live_games else self.update_interval + + if current_time - self.last_update >= interval: + self.last_update = current_time + + if self.test_mode: + # For testing, we'll just update the clock to show it's working + if self.current_game: + minutes = int(self.current_game["clock"].split(":")[0]) + seconds = int(self.current_game["clock"].split(":")[1]) + seconds -= 1 + if seconds < 0: + seconds = 59 + minutes -= 1 + if minutes < 0: + minutes = 19 + if self.current_game["period"] < 3: + self.current_game["period"] += 1 + else: + self.current_game["period"] = 1 + self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" + # Always update display in test mode + self.display(force_clear=True) + else: + # Fetch live game data from ESPN API + data = self._fetch_data() + if data and "events" in data: + # Find all live games involving favorite teams + new_live_games = [] + for event in data["events"]: + details = self._extract_game_details(event) + if details and details["is_live"]: + self._fetch_odds(details) + new_live_games.append(details) + + # Filter for favorite teams only if the config is set + if self.ncaam_hockey_config.get("show_favorite_teams_only", False): + new_live_games = [game for game in new_live_games + if game['home_abbr'] in self.favorite_teams or + game['away_abbr'] in self.favorite_teams] + + # Only log if there's a change in games or enough time has passed + should_log = ( + current_time - self.last_log_time >= self.log_interval or + len(new_live_games) != len(self.live_games) or + not self.live_games # Log if we had no games before + ) + + if should_log: + if new_live_games: + filter_text = "favorite teams" if self.ncaam_hockey_config.get("show_favorite_teams_only", False) else "all teams" + self.logger.info(f"[NCAAMH] Found {len(new_live_games)} live games involving {filter_text}") + for game in new_live_games: + self.logger.info(f"[NCAAMH] Live game: {game['away_abbr']} vs {game['home_abbr']} - Period {game['period']}, {game['clock']}") + else: + filter_text = "favorite teams" if self.ncaam_hockey_config.get("show_favorite_teams_only", False) else "criteria" + self.logger.info(f"[NCAAMH] No live games found matching {filter_text}") + self.last_log_time = current_time + + if new_live_games: + # Update the current game with the latest data + for new_game in new_live_games: + if self.current_game and ( + (new_game["home_abbr"] == self.current_game["home_abbr"] and + new_game["away_abbr"] == self.current_game["away_abbr"]) or + (new_game["home_abbr"] == self.current_game["away_abbr"] and + new_game["away_abbr"] == self.current_game["home_abbr"]) + ): + self.current_game = new_game + break + + # Only update the games list if we have new games + if not self.live_games or set(game["away_abbr"] + game["home_abbr"] for game in new_live_games) != set(game["away_abbr"] + game["home_abbr"] for game in self.live_games): + self.live_games = new_live_games + # If we don't have a current game or it's not in the new list, start from the beginning + if not self.current_game or self.current_game not in self.live_games: + self.current_game_index = 0 + self.current_game = self.live_games[0] + self.last_game_switch = current_time + + # Update display if data changed, limit rate + if current_time - self.last_display_update >= 1.0: + # self.display(force_clear=True) # REMOVED: DisplayController handles this + self.last_display_update = current_time + + else: + # No live games found + self.live_games = [] + self.current_game = None + + # Check if it's time to switch games + if len(self.live_games) > 1 and (current_time - self.last_game_switch) >= self.game_display_duration: + self.current_game_index = (self.current_game_index + 1) % len(self.live_games) + self.current_game = self.live_games[self.current_game_index] + self.last_game_switch = current_time + # self.display(force_clear=True) # REMOVED: DisplayController handles this + self.last_display_update = current_time # Track time for potential display update + + def display(self, force_clear=False): + """Display live game information.""" + if not self.current_game: + return + super().display(force_clear) # Call parent class's display method + + +class NCAAMHockeyRecentManager(BaseNCAAMHockeyManager): + """Manager for recently completed NCAAMH 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 = 300 # 5 minutes + self.recent_games_to_show = self.ncaam_hockey_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.info(f"Initialized NCAAMHRecentManager with {len(self.favorite_teams)} favorite teams") + + def update(self): + """Update recent games data.""" + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + self.last_update = current_time + + try: + # Fetch data from ESPN API + data = self._fetch_data() + if not data or 'events' not in data: + self.logger.warning("[NCAAMH] No events found in ESPN API response") + return + + events = data['events'] + self.logger.info(f"[NCAAMH] Successfully fetched {len(events)} events from ESPN API") + + # Process games + processed_games = [] + for event in events: + game = self._extract_game_details(event) + if game and game['is_final']: + # Fetch odds if enabled + self._fetch_odds(game) + processed_games.append(game) + + # Filter for favorite teams only if the config is set + if self.ncaam_hockey_config.get("show_favorite_teams_only", False): + team_games = [game for game in processed_games + if game['home_abbr'] in self.favorite_teams or + game['away_abbr'] in self.favorite_teams] + else: + team_games = processed_games + + # Sort games by start time, most recent first, then limit to recent_games_to_show + team_games.sort(key=lambda x: x.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) + team_games = team_games[:self.recent_games_to_show] + + self.logger.info(f"[NCAAMH] Found {len(team_games)} recent games for favorite teams (limited to {self.recent_games_to_show})") + + new_game_ids = {g['id'] for g in team_games} + current_game_ids = {g['id'] for g in getattr(self, 'games_list', [])} + + if new_game_ids != current_game_ids: + self.games_list = team_games + self.current_game_index = 0 + self.current_game = self.games_list[0] if self.games_list else None + self.last_game_switch = current_time + elif self.games_list: + self.current_game = self.games_list[self.current_game_index] + + if not self.games_list: + self.current_game = None + + except Exception as e: + self.logger.error(f"[NCAAMH] Error updating recent games: {e}", exc_info=True) + + def display(self, force_clear=False): + """Display recent games.""" + if not self.games_list: + self.logger.info("[NCAAMH] No recent games to display") + return # Skip display update entirely + + try: + current_time = time.time() + + # Check if it's time to switch games + if len(self.games_list) > 1 and current_time - self.last_game_switch >= self.game_display_duration: + # Move to next game + self.current_game_index = (self.current_game_index + 1) % len(self.games_list) + self.current_game = self.games_list[self.current_game_index] + self.last_game_switch = current_time + force_clear = True # Force clear when switching games + + # Draw the scorebug layout + self._draw_scorebug_layout(self.current_game, force_clear) + + # Update display + self.display_manager.update_display() + + except Exception as e: + self.logger.error(f"[NCAAMH] Error displaying recent game: {e}", exc_info=True) + +class NCAAMHockeyUpcomingManager(BaseNCAAMHockeyManager): + """Manager for upcoming 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.upcoming_games = [] + self.current_game_index = 0 + self.last_update = 0 + self.update_interval = 300 # 5 minutes + self.upcoming_games_to_show = self.ncaam_hockey_config.get("upcoming_games_to_show", 5) # Number of upcoming games to display + self.last_log_time = 0 + self.log_interval = 300 # Only log status every 5 minutes + self.last_warning_time = 0 + self.warning_cooldown = 300 # Only show warning every 5 minutes + self.last_game_switch = 0 # Track when we last switched games + self.game_display_duration = 15 # Display each game for 15 seconds + self.logger.info(f"Initialized NCAAMHUpcomingManager with {len(self.favorite_teams)} favorite teams") + + def update(self): + """Update upcoming games data.""" + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + self.last_update = current_time + + try: + # Fetch data from ESPN API + data = self._fetch_data() + if not data or 'events' not in data: + self.logger.warning("[NCAAMH] No events found in ESPN API response") + return + + events = data['events'] + self.logger.info(f"[NCAAMH] Successfully fetched {len(events)} events from ESPN API") + + # Process games + new_upcoming_games = [] + for event in events: + game = self._extract_game_details(event) + if game and game['is_upcoming']: + # Only fetch odds for games that will be displayed + if self.ncaam_hockey_config.get("show_favorite_teams_only", False): + if not self.favorite_teams or (game['home_abbr'] not in self.favorite_teams and game['away_abbr'] not in self.favorite_teams): + continue + + self._fetch_odds(game) + new_upcoming_games.append(game) + + # Filter for favorite teams only if the config is set + if self.ncaam_hockey_config.get("show_favorite_teams_only", False): + 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] + else: + team_games = new_upcoming_games + + # Sort games by start time, soonest first, then limit to configured count + team_games.sort(key=lambda x: x.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) + team_games = team_games[:self.upcoming_games_to_show] + + # Only log if there's a change in games or enough time has passed + should_log = ( + current_time - self.last_log_time >= self.log_interval or + len(team_games) != len(self.upcoming_games) or + not self.upcoming_games # Log if we had no games before + ) + + if should_log: + if team_games: + self.logger.info(f"[NCAAMH] Found {len(team_games)} upcoming games for favorite teams (limited to {self.upcoming_games_to_show})") + for game in team_games: + self.logger.info(f"[NCAAMH] Upcoming game: {game['away_abbr']} vs {game['home_abbr']} - {game['game_date']} {game['game_time']}") + else: + self.logger.info("[NCAAMH] No upcoming games found for favorite teams") + self.logger.debug(f"[NCAAMH] Favorite teams: {self.favorite_teams}") + self.last_log_time = current_time + + self.upcoming_games = team_games + if self.upcoming_games: + if not self.current_game or self.current_game['id'] not in {g['id'] for g in self.upcoming_games}: + self.current_game_index = 0 + self.current_game = self.upcoming_games[0] + self.last_game_switch = current_time + else: + self.current_game = None + + except Exception as e: + self.logger.error(f"[NCAAMH] Error updating upcoming games: {e}", exc_info=True) + + def display(self, force_clear=False): + """Display upcoming games.""" + if not self.upcoming_games: + current_time = time.time() + if current_time - self.last_warning_time > self.warning_cooldown: + self.logger.info("[NCAAMH] No upcoming games to display") + self.last_warning_time = current_time + return # Skip display update entirely + + try: + current_time = time.time() + + # Check if it's time to switch games + if len(self.upcoming_games) > 1 and current_time - self.last_game_switch >= self.game_display_duration: + # Move to next game + self.current_game_index = (self.current_game_index + 1) % len(self.upcoming_games) + self.current_game = self.upcoming_games[self.current_game_index] + self.last_game_switch = current_time + force_clear = True # Force clear when switching games + + # Draw the scorebug layout + self._draw_scorebug_layout(self.current_game, force_clear) + + # Update display + self.display_manager.update_display() + + except Exception as e: + self.logger.error(f"[NCAAMH] Error displaying upcoming game: {e}", exc_info=True) \ No newline at end of file diff --git a/src/nfl_managers.py b/src/nfl_managers.py index e238c786..f7f37289 100644 --- a/src/nfl_managers.py +++ b/src/nfl_managers.py @@ -154,8 +154,8 @@ class BaseNFLManager: # Renamed class self.logger.info(f"[NFL] Fetching full {current_year} season schedule from ESPN API (cache_enabled={use_cache})...") try: - url = f"https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard?dates={current_year}" - response = self.session.get(url, headers=self.headers, timeout=15) + url = f"https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard" + response = self.session.get(url, params={"dates": current_year, "limit":1000}, headers=self.headers, timeout=15) response.raise_for_status() data = response.json() events = data.get('events', []) diff --git a/src/of_the_day_manager.py b/src/of_the_day_manager.py index ab73dec4..a72a9300 100644 --- a/src/of_the_day_manager.py +++ b/src/of_the_day_manager.py @@ -1,11 +1,8 @@ import os import json import logging -from datetime import datetime, date -from PIL import Image, ImageDraw, ImageFont -import numpy as np -from rgbmatrix import graphics -import pytz +from datetime import date +from PIL import ImageDraw, ImageFont from src.config_manager import ConfigManager import time try: diff --git a/src/youtube_display.py b/src/youtube_display.py index 3299b499..d9feed1c 100644 --- a/src/youtube_display.py +++ b/src/youtube_display.py @@ -4,8 +4,6 @@ import time import logging from PIL import Image, ImageDraw, ImageFont import requests -from rgbmatrix import RGBMatrix, RGBMatrixOptions -import os from typing import Dict, Any # Import the API counter function from web interface