From 8a0fdb005dd97158f52ed570783e8f6832dc0c45 Mon Sep 17 00:00:00 2001 From: ChuckBuilds <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 4 Sep 2025 22:18:01 -0400 Subject: [PATCH] update UTC timezone logic to check config settings for all managers --- assets/sports/mlb_logos/mlb.png | Bin 0 -> 273 bytes assets/sports/nba_logos/nba.png | Bin 0 -> 278 bytes assets/sports/ncaa_fbs_logos/ncaa_fb.png | Bin 0 -> 418 bytes assets/sports/ncaa_fbs_logos/ncaam.png | Bin 0 -> 434 bytes assets/sports/nfl_logos/nfl.png | Bin 0 -> 253 bytes assets/sports/nhl_logos/nhl.png | Bin 0 -> 236 bytes config/config.json | 40 ++ create_league_logos.py | 1 + create_ncaa_logos.py | 1 + src/calendar_manager.py | 3 +- src/clock.py | 2 +- src/display_controller.py | 5 + src/leaderboard_manager.py | 589 +++++++++++++++++++++++ src/nba_managers.py | 3 +- src/ncaa_fb_managers.py | 3 +- src/ncaam_basketball_managers.py | 3 +- src/nfl_managers.py | 3 +- src/nhl_managers.py | 3 +- test/debug_espn_api.py | 85 ++++ test/test_leaderboard.py | 99 ++++ test/test_leaderboard_simple.py | 205 ++++++++ 21 files changed, 1037 insertions(+), 8 deletions(-) create mode 100644 assets/sports/mlb_logos/mlb.png create mode 100644 assets/sports/nba_logos/nba.png create mode 100644 assets/sports/ncaa_fbs_logos/ncaa_fb.png create mode 100644 assets/sports/ncaa_fbs_logos/ncaam.png create mode 100644 assets/sports/nfl_logos/nfl.png create mode 100644 assets/sports/nhl_logos/nhl.png create mode 100644 create_league_logos.py create mode 100644 create_ncaa_logos.py create mode 100644 src/leaderboard_manager.py create mode 100644 test/debug_espn_api.py create mode 100644 test/test_leaderboard.py create mode 100644 test/test_leaderboard_simple.py diff --git a/assets/sports/mlb_logos/mlb.png b/assets/sports/mlb_logos/mlb.png new file mode 100644 index 0000000000000000000000000000000000000000..72b8f29cab9b56256750e6275b9cb12548fe782f GIT binary patch literal 273 zcmeAS@N?(olHy`uVBq!ia0vp^DImZba5*$aJ2yuv+^fFZE!nN|`beEjDf`x0JN6psz;OYb{#hm;LsV-qI&?^r!aMJ<|Pr$m+C&m&=nm%YFYEP!N8ag8Pv$st7#^Fq zMk0Cr`pmx-vo^S2*Z*k|f7{>inECwIoS!FcJw9WW>E^B1HWnN!^vb;VP)&XHluP&L zhKs#C`$I7J{+hhnY`*sOagom(-e(=o?XKMX?pH!LBiw;N#)s^4ubv(B;;~C|0111# L`njxgN@xNAoW^q* literal 0 HcmV?d00001 diff --git a/assets/sports/ncaa_fbs_logos/ncaa_fb.png b/assets/sports/ncaa_fbs_logos/ncaa_fb.png new file mode 100644 index 0000000000000000000000000000000000000000..13d09199c6e092de665c101062e73ac97128ee86 GIT binary patch literal 418 zcmeAS@N?(olHy`uVBq!ia0vp^DIm)ZXy+0IuBC=gowjFVO zrt7+1IelBPQ}~rLYq-64?J?~Ss_Tf%Trs7qQqalZn9r|i=D+t;(e*?wT5XQXPIVio)-H-dqt%B xou5%V>ty#-oShZ=!20yRJ52n@!31I#|6#ba|9AhVXaBoETu)a&mvv4FO#to4yLJEo literal 0 HcmV?d00001 diff --git a/assets/sports/ncaa_fbs_logos/ncaam.png b/assets/sports/ncaa_fbs_logos/ncaam.png new file mode 100644 index 0000000000000000000000000000000000000000..17219c6e585a3fdc52d945bffe591630ddf98b35 GIT binary patch literal 434 zcmeAS@N?(olHy`uVBq!ia0vp^DIm-w^?khKIM# zR9oh^nO|1lEWSzU>P8dS^iTESy)tgSi&x!d18PGCceMVT@GbyO}g zGCdW#Mw{1P_SVWHcd|pI)HLP27yc6PxH`G~?Uo~Vk{qS9mb$pO^ccSMl)rr3^7-ER z_m{e5U(ST85u{Z>6^i%qf0(=T*qQ+hT%i zOIKYLH9mVv{Nku@2zQ^$U*e_*6`fPJI}>(!gHn>+#WFLU z>*3t5TjXoJGJmp`K8Z@M2+&L|sox9DMDFVdQ&MBb@0MOi5umAu6 literal 0 HcmV?d00001 diff --git a/config/config.json b/config/config.json index b2e6bd21..b78a312f 100644 --- a/config/config.json +++ b/config/config.json @@ -39,6 +39,7 @@ "daily_forecast": 30, "stock_news": 20, "odds_ticker": 60, + "leaderboard": 60, "nhl_live": 30, "nhl_recent": 30, "nhl_upcoming": 30, @@ -152,6 +153,45 @@ "max_duration": 300, "duration_buffer": 0.1 }, + "leaderboard": { + "enabled": false, + "enabled_sports": { + "nfl": { + "enabled": false, + "top_teams": 10 + }, + "nba": { + "enabled": false, + "top_teams": 10 + }, + "mlb": { + "enabled": false, + "top_teams": 10 + }, + "ncaa_fb": { + "enabled": false, + "top_teams": 25 + }, + "nhl": { + "enabled": false, + "top_teams": 10 + }, + "ncaam_basketball": { + "enabled": false, + "top_teams": 25 + } + }, + "update_interval": 3600, + "scroll_speed": 2, + "scroll_delay": 0.05, + "display_duration": 60, + "loop": true, + "request_timeout": 30, + "dynamic_duration": true, + "min_duration": 30, + "max_duration": 300, + "duration_buffer": 0.1 + }, "calendar": { "enabled": false, "credentials_file": "credentials.json", diff --git a/create_league_logos.py b/create_league_logos.py new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/create_league_logos.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/create_ncaa_logos.py b/create_ncaa_logos.py new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/create_ncaa_logos.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/calendar_manager.py b/src/calendar_manager.py index 338e3b5d..0cb33a56 100644 --- a/src/calendar_manager.py +++ b/src/calendar_manager.py @@ -42,8 +42,7 @@ class CalendarManager: logger.info(f"Calendar configuration: enabled={self.enabled}, update_interval={self.update_interval}, max_events={self.max_events}, calendars={self.calendars}") # Get timezone from config - self.config_manager = ConfigManager() - timezone_str = self.config_manager.get_timezone() + timezone_str = self.config.get('timezone', 'UTC') logger.info(f"Loading timezone from config: {timezone_str}") try: self.timezone = pytz.timezone(timezone_str) diff --git a/src/clock.py b/src/clock.py index 034ad40c..4091949c 100644 --- a/src/clock.py +++ b/src/clock.py @@ -31,7 +31,7 @@ class Clock: def _get_timezone(self) -> pytz.timezone: """Get timezone from the config file.""" - config_timezone = self.config_manager.get_timezone() + config_timezone = self.config.get('timezone', 'UTC') try: return pytz.timezone(config_timezone) except pytz.exceptions.UnknownTimeZoneError: diff --git a/src/display_controller.py b/src/display_controller.py index 9c124011..3a60ffb7 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -20,6 +20,7 @@ from src.cache_manager import CacheManager from src.stock_manager import StockManager from src.stock_news_manager import StockNewsManager from src.odds_ticker_manager import OddsTickerManager +from src.leaderboard_manager import LeaderboardManager from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager from src.mlb_manager import MLBLiveManager, MLBRecentManager, MLBUpcomingManager @@ -60,6 +61,7 @@ class DisplayController: self.stocks = StockManager(self.config, self.display_manager) if self.config.get('stocks', {}).get('enabled', False) else None self.news = StockNewsManager(self.config, self.display_manager) if self.config.get('stock_news', {}).get('enabled', False) else None self.odds_ticker = OddsTickerManager(self.config, self.display_manager) if self.config.get('odds_ticker', {}).get('enabled', False) else None + self.leaderboard = LeaderboardManager(self.config, self.display_manager) if self.config.get('leaderboard', {}).get('enabled', False) else None self.calendar = CalendarManager(self.display_manager, self.config) if self.config.get('calendar', {}).get('enabled', False) else None self.youtube = YouTubeDisplay(self.display_manager, self.config) if self.config.get('youtube', {}).get('enabled', False) else None self.text_display = TextDisplay(self.display_manager, self.config) if self.config.get('text_display', {}).get('enabled', False) else None @@ -258,6 +260,7 @@ class DisplayController: if self.stocks: self.available_modes.append('stocks') if self.news: self.available_modes.append('stock_news') if self.odds_ticker: self.available_modes.append('odds_ticker') + if self.leaderboard: self.available_modes.append('leaderboard') if self.calendar: self.available_modes.append('calendar') if self.youtube: self.available_modes.append('youtube') if self.text_display: self.available_modes.append('text_display') @@ -1119,6 +1122,8 @@ class DisplayController: manager_to_display = self.news elif self.current_display_mode == 'odds_ticker' and self.odds_ticker: manager_to_display = self.odds_ticker + elif self.current_display_mode == 'leaderboard' and self.leaderboard: + manager_to_display = self.leaderboard elif self.current_display_mode == 'calendar' and self.calendar: manager_to_display = self.calendar elif self.current_display_mode == 'youtube' and self.youtube: diff --git a/src/leaderboard_manager.py b/src/leaderboard_manager.py new file mode 100644 index 00000000..7f8953b7 --- /dev/null +++ b/src/leaderboard_manager.py @@ -0,0 +1,589 @@ +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 +from display_manager import DisplayManager +from cache_manager import CacheManager +from config_manager import ConfigManager + +# Import the API counter function from web interface +try: + from web_interface_v2 import increment_api_counter +except ImportError: + # Fallback if web interface is not available + def increment_api_counter(kind: str, count: int = 1): + pass + +# Get logger +logger = logging.getLogger(__name__) + +class LeaderboardManager: + """Manager for displaying scrolling leaderboards for multiple sports leagues.""" + + def __init__(self, config: Dict[str, Any], display_manager: DisplayManager): + self.config = config + self.display_manager = display_manager + self.leaderboard_config = config.get('leaderboard', {}) + self.is_enabled = self.leaderboard_config.get('enabled', False) + self.enabled_sports = self.leaderboard_config.get('enabled_sports', {}) + self.update_interval = self.leaderboard_config.get('update_interval', 3600) + self.scroll_speed = self.leaderboard_config.get('scroll_speed', 2) + self.scroll_delay = self.leaderboard_config.get('scroll_delay', 0.05) + self.display_duration = self.leaderboard_config.get('display_duration', 30) + self.loop = self.leaderboard_config.get('loop', True) + self.request_timeout = self.leaderboard_config.get('request_timeout', 30) + + # Dynamic duration settings + self.dynamic_duration_enabled = self.leaderboard_config.get('dynamic_duration', True) + self.min_duration = self.leaderboard_config.get('min_duration', 30) + self.max_duration = self.leaderboard_config.get('max_duration', 300) + self.duration_buffer = self.leaderboard_config.get('duration_buffer', 0.1) + self.dynamic_duration = 60 # Default duration in seconds + self.total_scroll_width = 0 # Track total width for dynamic duration calculation + + # Initialize managers + self.cache_manager = CacheManager() + self.config_manager = ConfigManager() + + # State variables + self.last_update = 0 + self.scroll_position = 0 + self.last_scroll_time = 0 + self.leaderboard_data = [] + self.current_sport_index = 0 + self.leaderboard_image = None # This will hold the single, wide image + self.last_display_time = 0 + + # Font setup + self.fonts = self._load_fonts() + + # League configurations with ESPN API endpoints + self.league_configs = { + 'nfl': { + 'sport': 'football', + 'league': 'nfl', + 'logo_dir': 'assets/sports/nfl_logos', + 'league_logo': 'assets/sports/nfl_logos/nfl.png', + 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams', + 'enabled': self.enabled_sports.get('nfl', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('nfl', {}).get('top_teams', 10) + }, + 'nba': { + 'sport': 'basketball', + 'league': 'nba', + 'logo_dir': 'assets/sports/nba_logos', + 'league_logo': 'assets/sports/nba_logos/nba.png', + 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/teams', + 'enabled': self.enabled_sports.get('nba', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('nba', {}).get('top_teams', 10) + }, + 'mlb': { + 'sport': 'baseball', + 'league': 'mlb', + 'logo_dir': 'assets/sports/mlb_logos', + 'league_logo': 'assets/sports/mlb_logos/mlb.png', + 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/teams', + 'enabled': self.enabled_sports.get('mlb', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('mlb', {}).get('top_teams', 10) + }, + 'ncaa_fb': { + 'sport': 'football', + 'league': 'college-football', + 'logo_dir': 'assets/sports/ncaa_fbs_logos', + 'league_logo': 'assets/sports/ncaa_fbs_logos/ncaa_fb.png', + 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams', + 'enabled': self.enabled_sports.get('ncaa_fb', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('ncaa_fb', {}).get('top_teams', 25) + }, + 'nhl': { + 'sport': 'hockey', + 'league': 'nhl', + 'logo_dir': 'assets/sports/nhl_logos', + 'league_logo': 'assets/sports/nhl_logos/nhl.png', + 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/teams', + 'enabled': self.enabled_sports.get('nhl', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('nhl', {}).get('top_teams', 10) + }, + 'ncaam_basketball': { + 'sport': 'basketball', + 'league': 'mens-college-basketball', + 'logo_dir': 'assets/sports/ncaa_fbs_logos', + 'league_logo': 'assets/sports/ncaa_fbs_logos/ncaam.png', + 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/teams', + 'enabled': self.enabled_sports.get('ncaam_basketball', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('ncaam_basketball', {}).get('top_teams', 25) + } + } + + logger.info(f"LeaderboardManager initialized with enabled sports: {[k for k, v in self.league_configs.items() if v['enabled']]}") + + def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: + """Load fonts for the leaderboard display.""" + try: + return { + 'small': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 6), + 'medium': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8), + 'large': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + } + except Exception as e: + logger.error(f"Error loading fonts: {e}") + return { + 'small': ImageFont.load_default(), + 'medium': ImageFont.load_default(), + 'large': ImageFont.load_default() + } + + def _get_team_logo(self, team_abbr: str, logo_dir: str) -> Optional[Image.Image]: + """Get team logo from the configured directory.""" + if not team_abbr or not logo_dir: + logger.debug("Cannot get team logo with missing team_abbr or logo_dir") + return None + try: + logo_path = os.path.join(logo_dir, f"{team_abbr}.png") + logger.debug(f"Attempting to load logo from path: {logo_path}") + if os.path.exists(logo_path): + logo = Image.open(logo_path) + logger.debug(f"Successfully loaded logo for {team_abbr} from {logo_path}") + return logo + else: + logger.warning(f"Logo not found at path: {logo_path}") + return None + except Exception as e: + logger.error(f"Error loading logo for {team_abbr} from {logo_dir}: {e}") + return None + + def _get_league_logo(self, league_logo_path: str) -> Optional[Image.Image]: + """Get league logo from the configured path.""" + if not league_logo_path: + return None + try: + if os.path.exists(league_logo_path): + logo = Image.open(league_logo_path) + logger.debug(f"Successfully loaded league logo from {league_logo_path}") + return logo + else: + logger.warning(f"League logo not found at path: {league_logo_path}") + return None + except Exception as e: + logger.error(f"Error loading league logo from {league_logo_path}: {e}") + return None + + def _fetch_standings(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]]: + """Fetch standings for a specific league from ESPN API.""" + try: + # First, get all teams for the league + teams_url = league_config['teams_url'] + response = requests.get(teams_url, timeout=self.request_timeout) + response.raise_for_status() + data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) + + standings = [] + sports = data.get('sports', []) + + if not sports: + logger.warning(f"No sports data found for {league_config['league']}") + return [] + + leagues = sports[0].get('leagues', []) + if not leagues: + logger.warning(f"No leagues data found for {league_config['league']}") + return [] + + teams = leagues[0].get('teams', []) + if not teams: + logger.warning(f"No teams data found for {league_config['league']}") + return [] + + logger.info(f"Found {len(teams)} teams for {league_config['league']}") + + # For each team, fetch their individual record + for team_data in teams: + team = team_data.get('team', {}) + team_abbr = team.get('abbreviation') + team_name = team.get('name', 'Unknown') + + if not team_abbr: + logger.warning(f"No abbreviation found for team {team_name}") + continue + + # Fetch individual team record + team_record = self._fetch_team_record(team_abbr, league_config) + + if team_record: + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'wins': team_record.get('wins', 0), + 'losses': team_record.get('losses', 0), + 'ties': team_record.get('ties', 0), + 'win_percentage': team_record.get('win_percentage', 0) + }) + + # Sort by win percentage (descending) and limit to top teams + standings.sort(key=lambda x: x['win_percentage'], reverse=True) + top_teams = standings[:league_config['top_teams']] + + logger.info(f"Fetched {len(top_teams)} teams for {league_config['league']}") + return top_teams + + except Exception as e: + logger.error(f"Error fetching standings for {league_config['league']}: {e}") + return [] + + def _fetch_team_record(self, team_abbr: str, league_config: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Fetch individual team record from ESPN API.""" + try: + sport = league_config['sport'] + league = league_config['league'] + + # Use a more specific endpoint for college sports + if league == 'college-football': + url = f"https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams/{team_abbr}" + else: + url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/teams/{team_abbr}" + + response = requests.get(url, timeout=self.request_timeout) + response.raise_for_status() + data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) + + team_data = data.get('team', {}) + stats = team_data.get('stats', []) + + # Find wins and losses + wins = 0 + losses = 0 + ties = 0 + + for stat in stats: + if stat.get('name') == 'wins': + wins = stat.get('value', 0) + elif stat.get('name') == 'losses': + losses = stat.get('value', 0) + elif stat.get('name') == 'ties': + ties = stat.get('value', 0) + + # Calculate win percentage + total_games = wins + losses + ties + win_percentage = wins / total_games if total_games > 0 else 0 + + return { + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage + } + + except Exception as e: + logger.error(f"Error fetching record for {team_abbr} in league {league_config['league']}: {e}") + return None + + def _fetch_all_standings(self) -> List[Dict[str, Any]]: + """Fetch standings for all enabled leagues.""" + all_standings = [] + + for league_key, league_config in self.league_configs.items(): + if not league_config['enabled']: + continue + + logger.debug(f"Fetching standings for {league_key}") + standings = self._fetch_standings(league_config) + + if standings: + all_standings.append({ + 'league': league_key, + 'league_config': league_config, + 'teams': standings + }) + + return all_standings + + def _create_leaderboard_image(self) -> None: + """Create the scrolling leaderboard image.""" + if not self.leaderboard_data: + logger.warning("No leaderboard data available") + return + + try: + # Calculate total width needed + total_width = 0 + team_height = 16 # Height for each team entry + league_header_height = 20 # Height for league logo and name + spacing = 10 # Spacing between leagues + + # Calculate width for each league section + for league_data in self.leaderboard_data: + league_config = league_data['league_config'] + teams = league_data['teams'] + + # Width for league header (logo + name) + league_width = 200 # Base width for league section + + # Width for team entries (number + logo + name + record) + max_team_width = 0 + for i, team in enumerate(teams): + team_text = f"{i+1}. {team['abbreviation']} {team['wins']}-{team['losses']}" + if 'ties' in team: + team_text += f"-{team['ties']}" + + # Estimate text width (rough calculation) + text_width = len(team_text) * 6 # Approximate character width + team_width = 30 + text_width + 50 # Number + text + logo space + max_team_width = max(max_team_width, team_width) + + league_width = max(league_width, max_team_width) + total_width += league_width + spacing + + # Create the main image + height = self.display_manager.matrix.height + self.leaderboard_image = Image.new('RGB', (total_width, height), (0, 0, 0)) + draw = ImageDraw.Draw(self.leaderboard_image) + + current_x = 0 + + for league_data in self.leaderboard_data: + league_key = league_data['league'] + league_config = league_data['league_config'] + teams = league_data['teams'] + + # Draw league header + league_logo = self._get_league_logo(league_config['league_logo']) + if league_logo: + # Resize league logo to fit + logo_height = int(height * 0.4) + logo_width = int(logo_height * league_logo.width / league_logo.height) + league_logo = league_logo.resize((logo_width, logo_height), Image.Resampling.LANCZOS) + + # Paste league logo + logo_y = (height - logo_height) // 2 + self.leaderboard_image.paste(league_logo, (current_x + 5, logo_y), league_logo if league_logo.mode == 'RGBA' else None) + current_x += logo_width + 10 + + # Draw league name + league_name = league_key.upper().replace('_', ' ') + draw.text((current_x, 5), league_name, font=self.fonts['medium'], fill=(255, 255, 255)) + current_x += 150 + + # Draw team standings + team_y = league_header_height + for i, team in enumerate(teams): + if team_y + team_height > height: + break + + # Draw team number + number_text = f"{i+1}." + draw.text((current_x, team_y), number_text, font=self.fonts['small'], fill=(255, 255, 0)) + + # Draw team logo + team_logo = self._get_team_logo(team['abbreviation'], league_config['logo_dir']) + if team_logo: + # Resize team logo + logo_size = 12 + team_logo = team_logo.resize((logo_size, logo_size), Image.Resampling.LANCZOS) + + # Paste team logo + logo_x = current_x + 20 + logo_y_pos = team_y + 2 + self.leaderboard_image.paste(team_logo, (logo_x, logo_y_pos), team_logo if team_logo.mode == 'RGBA' else None) + + # Draw team name and record + team_text = f"{team['abbreviation']} {team['wins']}-{team['losses']}" + if 'ties' in team: + team_text += f"-{team['ties']}" + + draw.text((logo_x + logo_size + 5, team_y), team_text, font=self.fonts['small'], fill=(255, 255, 255)) + else: + # Fallback if no logo + team_text = f"{team['abbreviation']} {team['wins']}-{team['losses']}" + if 'ties' in team: + team_text += f"-{team['ties']}" + + draw.text((current_x + 20, team_y), team_text, font=self.fonts['small'], fill=(255, 255, 255)) + + team_y += team_height + + current_x += 200 # Width for team section + current_x += spacing # Add spacing between leagues + + # Calculate dynamic duration based on total width + if self.dynamic_duration_enabled: + scroll_time = (total_width / self.scroll_speed) * self.scroll_delay + self.dynamic_duration = max(self.min_duration, min(self.max_duration, scroll_time + self.duration_buffer)) + logger.info(f"Calculated dynamic duration: {self.dynamic_duration:.1f}s for width {total_width}") + + self.total_scroll_width = total_width + logger.info(f"Created leaderboard image with width {total_width}") + + except Exception as e: + logger.error(f"Error creating leaderboard image: {e}") + self.leaderboard_image = None + + def update(self) -> None: + """Update leaderboard data.""" + current_time = time.time() + + if current_time - self.last_update < self.update_interval: + return + + logger.info("Updating leaderboard data") + + try: + self.leaderboard_data = self._fetch_all_standings() + self.last_update = current_time + + if self.leaderboard_data: + self._create_leaderboard_image() + else: + logger.warning("No leaderboard data fetched") + + except Exception as e: + logger.error(f"Error updating leaderboard: {e}") + + def _display_fallback_message(self) -> None: + """Display a fallback message when no data is available.""" + try: + width = self.display_manager.matrix.width + height = self.display_manager.matrix.height + + # Create a simple text image + image = Image.new('RGB', (width, height), (0, 0, 0)) + draw = ImageDraw.Draw(image) + + text = "No Leaderboard Data" + text_bbox = draw.textbbox((0, 0), text, font=self.fonts['medium']) + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + + x = (width - text_width) // 2 + y = (height - text_height) // 2 + + draw.text((x, y), text, font=self.fonts['medium'], fill=(255, 255, 255)) + + self.display_manager.set_image(image) + + except Exception as e: + logger.error(f"Error displaying fallback message: {e}") + + def display(self, force_clear: bool = False) -> None: + """Display the leaderboard.""" + logger.debug("Entering leaderboard display method") + logger.debug(f"Leaderboard enabled: {self.is_enabled}") + logger.debug(f"Current scroll position: {self.scroll_position}") + logger.debug(f"Leaderboard image width: {self.leaderboard_image.width if self.leaderboard_image else 'None'}") + logger.debug(f"Dynamic duration: {self.dynamic_duration}s") + + if not self.is_enabled: + logger.debug("Leaderboard is disabled, exiting display method.") + return + + # Reset display start time when force_clear is True or when starting fresh + if force_clear or not hasattr(self, '_display_start_time'): + self._display_start_time = time.time() + logger.debug(f"Reset/initialized display start time: {self._display_start_time}") + # Also reset scroll position for clean start + self.scroll_position = 0 + else: + # Check if the display start time is too old (more than 2x the dynamic duration) + current_time = time.time() + elapsed_time = current_time - self._display_start_time + if elapsed_time > (self.dynamic_duration * 2): + logger.debug(f"Display start time is too old ({elapsed_time:.1f}s), resetting") + self._display_start_time = current_time + self.scroll_position = 0 + + logger.debug(f"Number of leagues in data at start of display method: {len(self.leaderboard_data)}") + if not self.leaderboard_data: + logger.warning("Leaderboard has no data. Attempting to update...") + self.update() + if not self.leaderboard_data: + logger.warning("Still no data after update. Displaying fallback message.") + self._display_fallback_message() + return + + if self.leaderboard_image is None: + logger.warning("Leaderboard image is not available. Attempting to create it.") + self._create_leaderboard_image() + if self.leaderboard_image is None: + logger.error("Failed to create leaderboard image.") + self._display_fallback_message() + return + + try: + current_time = time.time() + + # Check if we should be scrolling + should_scroll = current_time - self.last_scroll_time >= self.scroll_delay + + # Signal scrolling state to display manager + if should_scroll: + self.display_manager.set_scrolling_state(True) + else: + # If we're not scrolling, check if we should process deferred updates + self.display_manager.process_deferred_updates() + + # Scroll the image + if should_scroll: + self.scroll_position += self.scroll_speed + self.last_scroll_time = current_time + + # Calculate crop region + width = self.display_manager.matrix.width + height = self.display_manager.matrix.height + + # Handle looping based on configuration + if self.loop: + # Reset position when we've scrolled past the end for a continuous loop + if self.scroll_position >= self.leaderboard_image.width: + logger.debug(f"Leaderboard loop reset: scroll_position {self.scroll_position} >= image width {self.leaderboard_image.width}") + self.scroll_position = 0 + else: + # Stop scrolling when we reach the end + if self.scroll_position >= self.leaderboard_image.width - width: + logger.debug(f"Leaderboard reached end: scroll_position {self.scroll_position} >= {self.leaderboard_image.width - width}") + self.scroll_position = self.leaderboard_image.width - width + # Signal that scrolling has stopped + self.display_manager.set_scrolling_state(False) + + # Check if we're at a natural break point for mode switching + elapsed_time = current_time - self._display_start_time + remaining_time = self.dynamic_duration - elapsed_time + + # If we have less than 2 seconds remaining and we're not at a clean break point, + # try to complete the current league display + if remaining_time < 2.0 and self.scroll_position > 0: + # Calculate how much time we need to complete the current scroll position + frames_to_complete = (self.leaderboard_image.width - self.scroll_position) / self.scroll_speed + time_to_complete = frames_to_complete * self.scroll_delay + + if time_to_complete <= remaining_time: + # We have enough time to complete the scroll, continue normally + pass + else: + # Not enough time, reset to beginning for clean transition + logger.debug(f"Display ending soon, resetting scroll position for clean transition") + self.scroll_position = 0 + + # Create the visible part of the image by cropping from the leaderboard_image + visible_image = self.leaderboard_image.crop(( + self.scroll_position, + 0, + self.scroll_position + width, + height + )) + + # Display the visible portion + self.display_manager.set_image(visible_image) + + except Exception as e: + logger.error(f"Error in leaderboard display: {e}") + self._display_fallback_message() diff --git a/src/nba_managers.py b/src/nba_managers.py index ba2bdd35..09a03711 100644 --- a/src/nba_managers.py +++ b/src/nba_managers.py @@ -79,7 +79,8 @@ class BaseNBAManager: def _get_timezone(self): try: - return pytz.timezone(self.config_manager.get_timezone()) + timezone_str = self.config.get('timezone', 'UTC') + return pytz.timezone(timezone_str) except pytz.UnknownTimeZoneError: return pytz.utc diff --git a/src/ncaa_fb_managers.py b/src/ncaa_fb_managers.py index 6436a1a7..e48c5b51 100644 --- a/src/ncaa_fb_managers.py +++ b/src/ncaa_fb_managers.py @@ -106,7 +106,8 @@ class BaseNCAAFBManager: # Renamed class def _get_timezone(self): try: - return pytz.timezone(self.config_manager.get_timezone()) + timezone_str = self.config.get('timezone', 'UTC') + return pytz.timezone(timezone_str) except pytz.UnknownTimeZoneError: return pytz.utc diff --git a/src/ncaam_basketball_managers.py b/src/ncaam_basketball_managers.py index d4dc967a..1a3f9926 100644 --- a/src/ncaam_basketball_managers.py +++ b/src/ncaam_basketball_managers.py @@ -107,7 +107,8 @@ class BaseNCAAMBasketballManager: def _get_timezone(self): try: - return pytz.timezone(self.config_manager.get_timezone()) + timezone_str = self.config.get('timezone', 'UTC') + return pytz.timezone(timezone_str) except pytz.UnknownTimeZoneError: return pytz.utc diff --git a/src/nfl_managers.py b/src/nfl_managers.py index e9f1528f..4393c463 100644 --- a/src/nfl_managers.py +++ b/src/nfl_managers.py @@ -87,7 +87,8 @@ class BaseNFLManager: # Renamed class def _get_timezone(self): try: - return pytz.timezone(self.config_manager.get_timezone()) + timezone_str = self.config.get('timezone', 'UTC') + return pytz.timezone(timezone_str) except pytz.UnknownTimeZoneError: return pytz.utc diff --git a/src/nhl_managers.py b/src/nhl_managers.py index f05fdec2..d8125ea1 100644 --- a/src/nhl_managers.py +++ b/src/nhl_managers.py @@ -107,7 +107,8 @@ class BaseNHLManager: def _get_timezone(self): try: - return pytz.timezone(self.config_manager.get_timezone()) + timezone_str = self.config.get('timezone', 'UTC') + return pytz.timezone(timezone_str) except pytz.UnknownTimeZoneError: return pytz.utc diff --git a/test/debug_espn_api.py b/test/debug_espn_api.py new file mode 100644 index 00000000..510e0444 --- /dev/null +++ b/test/debug_espn_api.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Debug script to examine ESPN API response structure +""" + +import requests +import json + +def debug_espn_api(): + """Debug ESPN API responses.""" + + # Test different endpoints + test_endpoints = [ + { + 'name': 'NFL Standings', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/standings' + }, + { + 'name': 'NFL Teams', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams' + }, + { + 'name': 'NFL Scoreboard', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard' + }, + { + 'name': 'NBA Teams', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/teams' + }, + { + 'name': 'MLB Teams', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/teams' + } + ] + + for endpoint in test_endpoints: + print(f"\n{'='*50}") + print(f"Testing {endpoint['name']}") + print(f"URL: {endpoint['url']}") + print('='*50) + + try: + response = requests.get(endpoint['url'], timeout=30) + response.raise_for_status() + data = response.json() + + print(f"Response status: {response.status_code}") + print(f"Response keys: {list(data.keys())}") + + # Print a sample of the response + if 'sports' in data: + sports = data['sports'] + print(f"Sports found: {len(sports)}") + if sports: + leagues = sports[0].get('leagues', []) + print(f"Leagues found: {len(leagues)}") + if leagues: + teams = leagues[0].get('teams', []) + print(f"Teams found: {len(teams)}") + if teams: + print("Sample team data:") + sample_team = teams[0] + print(f" Team: {sample_team.get('team', {}).get('name', 'Unknown')}") + print(f" Abbreviation: {sample_team.get('team', {}).get('abbreviation', 'Unknown')}") + stats = sample_team.get('stats', []) + print(f" Stats found: {len(stats)}") + for stat in stats[:3]: # Show first 3 stats + print(f" {stat.get('name', 'Unknown')}: {stat.get('value', 'Unknown')}") + + elif 'groups' in data: + groups = data['groups'] + print(f"Groups found: {len(groups)}") + if groups: + print("Sample group data:") + print(json.dumps(groups[0], indent=2)[:500] + "...") + + else: + print("Sample response data:") + print(json.dumps(data, indent=2)[:500] + "...") + + except Exception as e: + print(f"Error: {e}") + +if __name__ == "__main__": + debug_espn_api() diff --git a/test/test_leaderboard.py b/test/test_leaderboard.py new file mode 100644 index 00000000..54efa66e --- /dev/null +++ b/test/test_leaderboard.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +""" +Test script for the LeaderboardManager +""" + +import sys +import os +import json +import logging + +# Add the src directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from leaderboard_manager import LeaderboardManager +from display_manager import DisplayManager +from config_manager import ConfigManager + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +def test_leaderboard_manager(): + """Test the leaderboard manager functionality.""" + + # Load configuration + config_manager = ConfigManager() + config = config_manager.load_config() + + # Enable leaderboard and some sports for testing + config['leaderboard'] = { + 'enabled': True, + 'enabled_sports': { + 'nfl': { + 'enabled': True, + 'top_teams': 5 + }, + 'nba': { + 'enabled': True, + 'top_teams': 5 + }, + 'mlb': { + 'enabled': True, + 'top_teams': 5 + } + }, + 'update_interval': 3600, + 'scroll_speed': 2, + 'scroll_delay': 0.05, + 'display_duration': 60, + 'loop': True, + 'request_timeout': 30, + 'dynamic_duration': True, + 'min_duration': 30, + 'max_duration': 300, + 'duration_buffer': 0.1 + } + + # Initialize display manager (this will be a mock for testing) + display_manager = DisplayManager(config) + + # Initialize leaderboard manager + leaderboard_manager = LeaderboardManager(config, display_manager) + + print("Testing LeaderboardManager...") + print(f"Enabled: {leaderboard_manager.is_enabled}") + print(f"Enabled sports: {[k for k, v in leaderboard_manager.league_configs.items() if v['enabled']]}") + + # Test fetching standings + print("\nFetching standings...") + leaderboard_manager.update() + + print(f"Number of leagues with data: {len(leaderboard_manager.leaderboard_data)}") + + for league_data in leaderboard_manager.leaderboard_data: + league = league_data['league'] + teams = league_data['teams'] + print(f"\n{league.upper()}:") + for i, team in enumerate(teams[:5]): # Show top 5 + record = f"{team['wins']}-{team['losses']}" + if 'ties' in team: + record += f"-{team['ties']}" + print(f" {i+1}. {team['abbreviation']} {record}") + + # Test image creation + print("\nCreating leaderboard image...") + if leaderboard_manager.leaderboard_data: + leaderboard_manager._create_leaderboard_image() + if leaderboard_manager.leaderboard_image: + print(f"Image created successfully: {leaderboard_manager.leaderboard_image.size}") + print(f"Dynamic duration: {leaderboard_manager.dynamic_duration:.1f}s") + else: + print("Failed to create image") + else: + print("No data available to create image") + +if __name__ == "__main__": + test_leaderboard_manager() diff --git a/test/test_leaderboard_simple.py b/test/test_leaderboard_simple.py new file mode 100644 index 00000000..f65fb081 --- /dev/null +++ b/test/test_leaderboard_simple.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +Simple test script for the LeaderboardManager (without display dependencies) +""" + +import sys +import os +import json +import logging +import requests +from typing import Dict, Any, List, Optional +from datetime import datetime, timedelta, timezone +from PIL import Image, ImageDraw, ImageFont + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +logger = logging.getLogger(__name__) + +def test_espn_api(): + """Test ESPN API endpoints for standings.""" + + # Test different league endpoints + test_leagues = [ + { + 'name': 'NFL', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/standings' + }, + { + 'name': 'NBA', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/standings' + }, + { + 'name': 'MLB', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/standings' + } + ] + + for league in test_leagues: + print(f"\nTesting {league['name']} API...") + try: + response = requests.get(league['url'], timeout=30) + response.raise_for_status() + data = response.json() + + print(f"✓ {league['name']} API response successful") + + # Check if we have groups data + groups = data.get('groups', []) + print(f" Groups found: {len(groups)}") + + # Try to extract some team data + total_teams = 0 + for group in groups: + if 'standings' in group: + total_teams += len(group['standings']) + elif 'groups' in group: + # Handle nested groups (like NFL conferences/divisions) + for sub_group in group['groups']: + if 'standings' in sub_group: + total_teams += len(sub_group['standings']) + elif 'groups' in sub_group: + for sub_sub_group in sub_group['groups']: + if 'standings' in sub_sub_group: + total_teams += len(sub_sub_group['standings']) + + print(f" Total teams found: {total_teams}") + + except Exception as e: + print(f"✗ {league['name']} API failed: {e}") + +def test_standings_parsing(): + """Test parsing standings data.""" + + # Test NFL standings parsing using teams endpoint + print("\nTesting NFL standings parsing...") + try: + # First get all teams + teams_url = 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams' + response = requests.get(teams_url, timeout=30) + response.raise_for_status() + data = response.json() + + sports = data.get('sports', []) + if not sports: + print("✗ No sports data found") + return + + leagues = sports[0].get('leagues', []) + if not leagues: + print("✗ No leagues data found") + return + + teams = leagues[0].get('teams', []) + if not teams: + print("✗ No teams data found") + return + + print(f"Found {len(teams)} NFL teams") + + # Test fetching individual team records + standings = [] + test_teams = teams[:5] # Test first 5 teams to avoid too many API calls + + for team_data in test_teams: + team = team_data.get('team', {}) + team_abbr = team.get('abbreviation') + team_name = team.get('name', 'Unknown') + + if not team_abbr: + continue + + print(f" Fetching record for {team_abbr}...") + + # Fetch individual team record + team_url = f"https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams/{team_abbr}" + team_response = requests.get(team_url, timeout=30) + team_response.raise_for_status() + team_data = team_response.json() + + team_info = team_data.get('team', {}) + stats = team_info.get('stats', []) + + # Find wins and losses + wins = 0 + losses = 0 + ties = 0 + + for stat in stats: + if stat.get('name') == 'wins': + wins = stat.get('value', 0) + elif stat.get('name') == 'losses': + losses = stat.get('value', 0) + elif stat.get('name') == 'ties': + ties = stat.get('value', 0) + + # Calculate win percentage + total_games = wins + losses + ties + win_percentage = wins / total_games if total_games > 0 else 0 + + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage + }) + + # Sort by win percentage and show results + standings.sort(key=lambda x: x['win_percentage'], reverse=True) + + print("NFL team records:") + for i, team in enumerate(standings): + record = f"{team['wins']}-{team['losses']}" + if team['ties'] > 0: + record += f"-{team['ties']}" + print(f" {i+1}. {team['abbreviation']} {record} ({team['win_percentage']:.3f})") + + except Exception as e: + print(f"✗ NFL standings parsing failed: {e}") + +def test_logo_loading(): + """Test logo loading functionality.""" + + print("\nTesting logo loading...") + + # Test team logo loading + logo_dir = "assets/sports/nfl_logos" + test_teams = ["TB", "DAL", "NE"] + + for team in test_teams: + logo_path = os.path.join(logo_dir, f"{team}.png") + if os.path.exists(logo_path): + print(f"✓ {team} logo found: {logo_path}") + else: + print(f"✗ {team} logo not found: {logo_path}") + + # Test league logo loading + league_logos = [ + "assets/sports/nfl_logos/nfl.png", + "assets/sports/nba_logos/nba.png", + "assets/sports/mlb_logos/mlb.png", + "assets/sports/nhl_logos/nhl.png", + "assets/sports/ncaa_fbs_logos/ncaa_fb.png", + "assets/sports/ncaa_fbs_logos/ncaam.png" + ] + + for logo_path in league_logos: + if os.path.exists(logo_path): + print(f"✓ League logo found: {logo_path}") + else: + print(f"✗ League logo not found: {logo_path}") + +if __name__ == "__main__": + print("Testing LeaderboardManager components...") + + test_espn_api() + test_standings_parsing() + test_logo_loading() + + print("\nTest completed!")