diff --git a/assets/sports/ncaa_fbs_logos/AMH.png b/assets/sports/ncaa_fbs_logos/AMH.png new file mode 100644 index 00000000..c9f3ca86 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/AMH.png differ diff --git a/assets/sports/ncaa_fbs_logos/ANN.png b/assets/sports/ncaa_fbs_logos/ANN.png new file mode 100644 index 00000000..6c61e18b Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/ANN.png differ diff --git a/assets/sports/ncaa_fbs_logos/ASU.png b/assets/sports/ncaa_fbs_logos/ASU.png new file mode 100644 index 00000000..ec9e1162 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/ASU.png differ diff --git a/assets/sports/ncaa_fbs_logos/BOIS.png b/assets/sports/ncaa_fbs_logos/BOIS.png new file mode 100644 index 00000000..ccbd604e Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/BOIS.png differ diff --git a/assets/sports/ncaa_fbs_logos/BRST.png b/assets/sports/ncaa_fbs_logos/BRST.png new file mode 100644 index 00000000..e6bba244 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/BRST.png differ diff --git a/assets/sports/ncaa_fbs_logos/BUENA.png b/assets/sports/ncaa_fbs_logos/BUENA.png new file mode 100644 index 00000000..88c443b3 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/BUENA.png differ diff --git a/assets/sports/ncaa_fbs_logos/CAR.png b/assets/sports/ncaa_fbs_logos/CAR.png new file mode 100644 index 00000000..dff64804 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/CAR.png differ diff --git a/assets/sports/ncaa_fbs_logos/CLA.png b/assets/sports/ncaa_fbs_logos/CLA.png new file mode 100644 index 00000000..30eefb63 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/CLA.png differ diff --git a/assets/sports/ncaa_fbs_logos/COLBY.png b/assets/sports/ncaa_fbs_logos/COLBY.png new file mode 100644 index 00000000..5df6982f Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/COLBY.png differ diff --git a/assets/sports/ncaa_fbs_logos/CP.png b/assets/sports/ncaa_fbs_logos/CP.png new file mode 100644 index 00000000..80ac8ba5 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/CP.png differ diff --git a/assets/sports/ncaa_fbs_logos/CSU.png b/assets/sports/ncaa_fbs_logos/CSU.png new file mode 100644 index 00000000..5ade1fc8 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/CSU.png differ diff --git a/assets/sports/ncaa_fbs_logos/CUR.png b/assets/sports/ncaa_fbs_logos/CUR.png new file mode 100644 index 00000000..4c71f0be Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/CUR.png differ diff --git a/assets/sports/ncaa_fbs_logos/DEL.png b/assets/sports/ncaa_fbs_logos/DEL.png new file mode 100644 index 00000000..52d230ba Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/DEL.png differ diff --git a/assets/sports/ncaa_fbs_logos/DUB.png b/assets/sports/ncaa_fbs_logos/DUB.png new file mode 100644 index 00000000..cefe34e5 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/DUB.png differ diff --git a/assets/sports/ncaa_fbs_logos/ELM.png b/assets/sports/ncaa_fbs_logos/ELM.png new file mode 100644 index 00000000..19601cf7 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/ELM.png differ diff --git a/assets/sports/ncaa_fbs_logos/FAMU.png b/assets/sports/ncaa_fbs_logos/FAMU.png new file mode 100644 index 00000000..cbd35117 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/FAMU.png differ diff --git a/assets/sports/ncaa_fbs_logos/FLA.png b/assets/sports/ncaa_fbs_logos/FLA.png new file mode 100644 index 00000000..2383ea78 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/FLA.png differ diff --git a/assets/sports/ncaa_fbs_logos/GRI.png b/assets/sports/ncaa_fbs_logos/GRI.png new file mode 100644 index 00000000..1924fa7a Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/GRI.png differ diff --git a/assets/sports/ncaa_fbs_logos/GTWN.png b/assets/sports/ncaa_fbs_logos/GTWN.png new file mode 100644 index 00000000..9990feee Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/GTWN.png differ diff --git a/assets/sports/ncaa_fbs_logos/HAW.png b/assets/sports/ncaa_fbs_logos/HAW.png new file mode 100644 index 00000000..cd5856dc Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/HAW.png differ diff --git a/assets/sports/ncaa_fbs_logos/HOW.png b/assets/sports/ncaa_fbs_logos/HOW.png new file mode 100644 index 00000000..a4b902a9 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/HOW.png differ diff --git a/assets/sports/ncaa_fbs_logos/IDHO.png b/assets/sports/ncaa_fbs_logos/IDHO.png new file mode 100644 index 00000000..1cfcdfff Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/IDHO.png differ diff --git a/assets/sports/ncaa_fbs_logos/JXST.png b/assets/sports/ncaa_fbs_logos/JXST.png new file mode 100644 index 00000000..d507ee89 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/JXST.png differ diff --git a/assets/sports/ncaa_fbs_logos/LUT.png b/assets/sports/ncaa_fbs_logos/LUT.png new file mode 100644 index 00000000..684e10f2 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/LUT.png differ diff --git a/assets/sports/ncaa_fbs_logos/MESA.png b/assets/sports/ncaa_fbs_logos/MESA.png new file mode 100644 index 00000000..66159848 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/MESA.png differ diff --git a/assets/sports/ncaa_fbs_logos/MIL.png b/assets/sports/ncaa_fbs_logos/MIL.png new file mode 100644 index 00000000..adc29c39 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/MIL.png differ diff --git a/assets/sports/ncaa_fbs_logos/MOR.png b/assets/sports/ncaa_fbs_logos/MOR.png new file mode 100644 index 00000000..b701025e Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/MOR.png differ diff --git a/assets/sports/ncaa_fbs_logos/NOR.png b/assets/sports/ncaa_fbs_logos/NOR.png new file mode 100644 index 00000000..2b62fead Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/NOR.png differ diff --git a/assets/sports/ncaa_fbs_logos/RED.png b/assets/sports/ncaa_fbs_logos/RED.png new file mode 100644 index 00000000..196b387d Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/RED.png differ diff --git a/assets/sports/ncaa_fbs_logos/SAC.png b/assets/sports/ncaa_fbs_logos/SAC.png new file mode 100644 index 00000000..5ef0343f Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/SAC.png differ diff --git a/assets/sports/ncaa_fbs_logos/STET.png b/assets/sports/ncaa_fbs_logos/STET.png new file mode 100644 index 00000000..d31e923b Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/STET.png differ diff --git a/assets/sports/ncaa_fbs_logos/USA.png b/assets/sports/ncaa_fbs_logos/USA.png new file mode 100644 index 00000000..3083699d Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/USA.png differ diff --git a/assets/sports/ncaa_fbs_logos/YALE.png b/assets/sports/ncaa_fbs_logos/YALE.png new file mode 100644 index 00000000..fa375049 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/YALE.png differ diff --git a/config/config.json b/config/config.json index 1b05be1c..1575b845 100644 --- a/config/config.json +++ b/config/config.json @@ -189,9 +189,9 @@ "loop": true, "request_timeout": 30, "dynamic_duration": true, - "min_duration": 30, + "min_duration": 45, "max_duration": 300, - "duration_buffer": 0.1, + "duration_buffer": 0.2, "time_per_team": 2.0, "time_per_league": 3.0 }, diff --git a/src/leaderboard_manager.py b/src/leaderboard_manager.py index 31dbbb8a..948c88e4 100644 --- a/src/leaderboard_manager.py +++ b/src/leaderboard_manager.py @@ -11,11 +11,13 @@ 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 try: @@ -193,8 +195,8 @@ class LeaderboardManager: 'xlarge': ImageFont.load_default() } - def _get_team_logo(self, team_abbr: str, logo_dir: str) -> Optional[Image.Image]: - """Get team logo from the configured directory.""" + def _get_team_logo(self, team_abbr: str, logo_dir: str, league: str = None, team_name: str = None) -> Optional[Image.Image]: + """Get team logo from the configured directory, downloading if missing.""" if not team_abbr or not logo_dir: logger.debug("Cannot get team logo with missing team_abbr or logo_dir") return None @@ -207,6 +209,18 @@ class LeaderboardManager: return logo else: logger.warning(f"Logo not found at path: {logo_path}") + + # Try to download the missing logo if we have league information + if league: + logger.info(f"Attempting to download missing logo for {team_abbr} in league {league}") + success = download_missing_logo(team_abbr, league, team_name) + if success: + # Try to load the downloaded logo + if os.path.exists(logo_path): + logo = Image.open(logo_path) + logger.info(f"Successfully downloaded and loaded logo for {team_abbr}") + return logo + return None except Exception as e: logger.error(f"Error loading logo for {team_abbr} from {logo_dir}: {e}") @@ -767,19 +781,10 @@ class LeaderboardManager: league_logo = league_logo.resize((logo_width, logo_height), Image.Resampling.LANCZOS) self.leaderboard_image.paste(league_logo, (logo_x, logo_y), league_logo if league_logo.mode == 'RGBA' else None) - # Draw league name at the bottom - league_name = league_key.upper().replace('_', ' ') - league_name_bbox = self.fonts['small'].getbbox(league_name) - league_name_width = league_name_bbox[2] - league_name_bbox[0] - league_name_x = current_x + (64 - league_name_width) // 2 - draw.text((league_name_x, height - 8), league_name, font=self.fonts['small'], fill=(255, 255, 255)) + # League name removed - only show league logo else: - # Fallback if no league logo - just show league name - league_name = league_key.upper().replace('_', ' ') - league_name_bbox = self.fonts['medium'].getbbox(league_name) - league_name_width = league_name_bbox[2] - league_name_bbox[0] - league_name_x = current_x + (64 - league_name_width) // 2 - draw.text((league_name_x, height // 2), league_name, font=self.fonts['medium'], fill=(255, 255, 255)) + # No league logo available - skip league name display + pass # Move to team section current_x += 64 + 10 # League logo width + spacing @@ -816,7 +821,8 @@ class LeaderboardManager: draw.text((team_x, number_y), number_text, font=self.fonts['xlarge'], fill=(255, 255, 0)) # Draw team logo (95% of display height, centered vertically) - team_logo = self._get_team_logo(team['abbreviation'], league_config['logo_dir']) + team_logo = self._get_team_logo(team['abbreviation'], league_config['logo_dir'], + league=league_key, team_name=team.get('name')) if team_logo: # Resize team logo to dynamic size (95% of display height) team_logo = team_logo.resize((logo_size, logo_size), Image.Resampling.LANCZOS) diff --git a/src/logo_downloader.py b/src/logo_downloader.py new file mode 100644 index 00000000..9cbb2516 --- /dev/null +++ b/src/logo_downloader.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python3 +""" +Centralized logo downloader utility for automatically fetching team logos from ESPN API. +This module provides functionality to download missing team logos for various sports leagues, +with special support for FCS teams and other NCAA divisions. +""" + +import os +import time +import logging +import requests +import json +from typing import Dict, Any, List, Optional, Tuple +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +logger = logging.getLogger(__name__) + +class LogoDownloader: + """Centralized logo downloader for team logos from ESPN API.""" + + # ESPN API endpoints for different sports/leagues + API_ENDPOINTS = { + 'nfl': 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams', + 'nba': 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/teams', + 'mlb': 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/teams', + 'nhl': 'https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/teams', + 'ncaa_fb': 'https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams', + 'ncaa_fb_all': 'https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams', # Includes FCS + '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' + } + + # Directory mappings for different leagues + LOGO_DIRECTORIES = { + 'nfl': 'assets/sports/nfl_logos', + 'nba': 'assets/sports/nba_logos', + 'mlb': 'assets/sports/mlb_logos', + 'nhl': 'assets/sports/nhl_logos', + 'ncaa_fb': 'assets/sports/ncaa_fbs_logos', + 'ncaa_fb_all': 'assets/sports/ncaa_fbs_logos', # FCS teams go in same directory + 'fcs': 'assets/sports/ncaa_fbs_logos', # FCS teams go in same directory + 'ncaam_basketball': 'assets/sports/ncaa_fbs_logos', + 'ncaa_baseball': 'assets/sports/ncaa_fbs_logos' + } + + def __init__(self, request_timeout: int = 30, retry_attempts: int = 3): + """Initialize the logo downloader with HTTP session and retry logic.""" + self.request_timeout = request_timeout + self.retry_attempts = retry_attempts + + # Set up session with retry logic + self.session = requests.Session() + retry_strategy = Retry( + total=retry_attempts, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + 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' + } + + def normalize_abbreviation(self, abbreviation: str) -> str: + """Normalize team abbreviation for consistent filename usage.""" + return abbreviation.upper() + + def get_logo_directory(self, league: str) -> str: + """Get the logo directory for a given league.""" + return self.LOGO_DIRECTORIES.get(league, f'assets/sports/{league}_logos') + + def ensure_logo_directory(self, logo_dir: str) -> bool: + """Ensure the logo directory exists, create if necessary.""" + try: + os.makedirs(logo_dir, exist_ok=True) + return True + except Exception as e: + logger.error(f"Failed to create logo directory {logo_dir}: {e}") + return False + + def download_logo(self, logo_url: str, filepath: Path, team_name: str) -> bool: + """Download a single logo from URL and save to filepath.""" + try: + response = self.session.get(logo_url, headers=self.headers, timeout=self.request_timeout) + response.raise_for_status() + + # Verify it's actually an image + content_type = response.headers.get('content-type', '').lower() + if not any(img_type in content_type for img_type in ['image/png', 'image/jpeg', 'image/jpg', 'image/gif']): + logger.warning(f"Downloaded content for {team_name} is not an image: {content_type}") + return False + + with open(filepath, 'wb') as f: + f.write(response.content) + + # Verify the downloaded file is a valid image + try: + with Image.open(filepath) as img: + img.verify() + logger.info(f"Successfully downloaded logo for {team_name} -> {filepath.name}") + return True + except Exception as e: + logger.error(f"Downloaded file for {team_name} is not a valid image: {e}") + os.remove(filepath) # Remove invalid file + return False + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to download logo for {team_name}: {e}") + return False + except Exception as e: + logger.error(f"Unexpected error downloading logo for {team_name}: {e}") + return False + + def fetch_teams_data(self, league: str) -> Optional[Dict]: + """Fetch team data from ESPN API for a specific league.""" + api_url = self.API_ENDPOINTS.get(league) + if not api_url: + logger.error(f"No API endpoint configured for league: {league}") + return None + + 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.raise_for_status() + data = response.json() + + logger.info(f"Successfully fetched team data for {league}") + return data + + except requests.exceptions.RequestException as e: + logger.error(f"Error fetching team data for {league}: {e}") + return None + except json.JSONDecodeError as e: + logger.error(f"Error parsing JSON response for {league}: {e}") + return None + + def extract_teams_from_data(self, data: Dict, league: str) -> List[Dict[str, str]]: + """Extract team information from ESPN API response.""" + teams = [] + + try: + sports = data.get('sports', []) + for sport in sports: + leagues_data = sport.get('leagues', []) + for league_data in leagues_data: + teams_data = league_data.get('teams', []) + + for team_data in teams_data: + team_info = team_data.get('team', {}) + + abbreviation = team_info.get('abbreviation', '') + display_name = team_info.get('displayName', 'Unknown') + logos = team_info.get('logos', []) + + if not abbreviation or not logos: + continue + + # Get the default logo (first one is usually default) + logo_url = logos[0].get('href', '') + if not logo_url: + continue + + # For NCAA football, try to determine if it's FCS or FBS + team_category = 'FBS' # Default + if league in ['ncaa_fb', 'ncaa_fb_all', 'fcs']: + # Check if this is an FCS team by looking at conference or other indicators + # ESPN API includes both FBS and FCS teams in the same endpoint + # We'll include all teams and let the user decide which ones to use + team_category = self._determine_ncaa_football_division(team_info, league_data) + + teams.append({ + 'abbreviation': abbreviation, + 'display_name': display_name, + 'logo_url': logo_url, + 'league': league, + 'category': team_category, + 'conference': league_data.get('name', 'Unknown') + }) + + logger.info(f"Extracted {len(teams)} teams for {league}") + return teams + + except Exception as e: + logger.error(f"Error extracting teams for {league}: {e}") + return [] + + def _determine_ncaa_football_division(self, team_info: Dict, league_data: Dict) -> str: + """Determine if an NCAA football team is FBS or FCS based on conference and other indicators.""" + conference_name = league_data.get('name', '').lower() + + # FBS Conferences (more comprehensive list) + fbs_conferences = { + 'acc', 'american athletic', 'big 12', 'big ten', 'conference usa', 'c-usa', + 'mid-american', 'mac', 'mountain west', 'pac-12', 'pac-10', 'sec', + 'sun belt', 'independents', 'big east' + } + + # FCS Conferences (more comprehensive list) + fcs_conferences = { + 'big sky', 'big south', 'colonial athletic', 'caa', 'ivy league', + 'meac', 'missouri valley', 'mvfc', 'northeast', 'nec', + 'ohio valley', 'ovc', 'patriot league', 'pioneer football', + 'southland', 'southern', 'southwestern athletic', 'swac', + 'western athletic', 'wac', 'ncaa division i-aa' + } + + # Also check for specific team indicators + team_abbreviation = team_info.get('abbreviation', '').upper() + + # Known FBS teams that might be misclassified + known_fbs_teams = { + 'ASU', 'ARIZ', 'ARK', 'AUB', 'BOIS', 'CSU', 'FLA', 'HAW', 'IDHO', 'USA' + } + + # Check if it's a known FBS team first + if team_abbreviation in known_fbs_teams: + return 'FBS' + + # Check conference names + if any(fbs_conf in conference_name for fbs_conf in fbs_conferences): + return 'FBS' + elif any(fcs_conf in conference_name for fcs_conf in fcs_conferences): + return 'FCS' + + # If conference is just "NCAA - Football", we need to use other indicators + if conference_name == 'ncaa - football': + # Check team name for indicators of FCS (smaller schools, Division II/III) + team_name = team_info.get('displayName', '').lower() + fcs_indicators = ['college', 'university', 'state', 'tech', 'community'] + + # If it has typical FCS naming patterns and isn't a known FBS team + if any(indicator in team_name for indicator in fcs_indicators): + return 'FCS' + else: + return 'FBS' + + # Default to FBS for unknown conferences + return 'FBS' + + def download_missing_logos_for_league(self, league: str, force_download: bool = False) -> Tuple[int, int]: + """Download missing logos for a specific league.""" + logger.info(f"Starting logo download for league: {league}") + + # Get logo directory + logo_dir = self.get_logo_directory(league) + if not self.ensure_logo_directory(logo_dir): + logger.error(f"Failed to create logo directory for {league}") + return 0, 0 + + # Fetch team data + data = self.fetch_teams_data(league) + if not data: + logger.error(f"Failed to fetch team data for {league}") + return 0, 0 + + # Extract teams + teams = self.extract_teams_from_data(data, league) + if not teams: + logger.warning(f"No teams found for {league}") + return 0, 0 + + # Download missing logos + downloaded_count = 0 + failed_count = 0 + + for team in teams: + abbreviation = team['abbreviation'] + display_name = team['display_name'] + logo_url = team['logo_url'] + + # Create filename + filename = f"{self.normalize_abbreviation(abbreviation)}.png" + filepath = Path(logo_dir) / filename + + # Skip if already exists and not forcing download + if filepath.exists() and not force_download: + logger.debug(f"Skipping {display_name}: {filename} already exists") + continue + + # Download logo + if self.download_logo(logo_url, filepath, display_name): + downloaded_count += 1 + else: + failed_count += 1 + + # Small delay to be respectful to the API + time.sleep(0.1) + + logger.info(f"Logo download complete for {league}: {downloaded_count} downloaded, {failed_count} failed") + return downloaded_count, failed_count + + def download_all_ncaa_football_logos(self, include_fcs: bool = True, force_download: bool = False) -> Tuple[int, int]: + """Download all NCAA football team logos including FCS teams.""" + logger.info(f"Starting comprehensive NCAA football logo download (FCS: {include_fcs})") + + # Use the comprehensive NCAA football endpoint + league = 'ncaa_fb_all' + logo_dir = self.get_logo_directory(league) + if not self.ensure_logo_directory(logo_dir): + logger.error(f"Failed to create logo directory for {league}") + return 0, 0 + + # Fetch team data + data = self.fetch_teams_data(league) + if not data: + logger.error(f"Failed to fetch team data for {league}") + return 0, 0 + + # Extract teams + teams = self.extract_teams_from_data(data, league) + if not teams: + logger.warning(f"No teams found for {league}") + return 0, 0 + + # Filter teams based on FCS inclusion + if not include_fcs: + teams = [team for team in teams if team.get('category') == 'FBS'] + logger.info(f"Filtered to FBS teams only: {len(teams)} teams") + + # Download missing logos + downloaded_count = 0 + failed_count = 0 + + for team in teams: + abbreviation = team['abbreviation'] + display_name = team['display_name'] + logo_url = team['logo_url'] + category = team.get('category', 'Unknown') + conference = team.get('conference', 'Unknown') + + # Create filename + filename = f"{self.normalize_abbreviation(abbreviation)}.png" + filepath = Path(logo_dir) / filename + + # Skip if already exists and not forcing download + if filepath.exists() and not force_download: + logger.debug(f"Skipping {display_name} ({category}, {conference}): {filename} already exists") + continue + + # Download logo + if self.download_logo(logo_url, filepath, display_name): + downloaded_count += 1 + logger.info(f"Downloaded {display_name} ({category}, {conference}) -> {filename}") + else: + failed_count += 1 + logger.warning(f"Failed to download {display_name} ({category}, {conference})") + + # Small delay to be respectful to the API + time.sleep(0.1) + + logger.info(f"Comprehensive NCAA football logo download complete: {downloaded_count} downloaded, {failed_count} failed") + return downloaded_count, failed_count + + def download_missing_logo_for_team(self, team_abbreviation: str, league: str, team_name: str = None) -> bool: + """Download a specific team's logo if it's missing.""" + logo_dir = self.get_logo_directory(league) + if not self.ensure_logo_directory(logo_dir): + return False + + filename = f"{self.normalize_abbreviation(team_abbreviation)}.png" + filepath = Path(logo_dir) / filename + + # Return True if logo already exists + if filepath.exists(): + logger.debug(f"Logo already exists for {team_abbreviation}") + return True + + # Fetch team data to find the logo URL + data = self.fetch_teams_data(league) + if not data: + return False + + teams = self.extract_teams_from_data(data, league) + + # Find the specific team + target_team = None + for team in teams: + if team['abbreviation'].upper() == team_abbreviation.upper(): + target_team = team + break + + if not target_team: + logger.warning(f"Team {team_abbreviation} not found in {league} data") + return False + + # Download the logo + success = self.download_logo(target_team['logo_url'], filepath, target_team['display_name']) + if success: + time.sleep(0.1) # Small delay + return success + + def download_all_missing_logos(self, leagues: List[str] = None, force_download: bool = False) -> Dict[str, Tuple[int, int]]: + """Download missing logos for all specified leagues.""" + if leagues is None: + leagues = list(self.API_ENDPOINTS.keys()) + + results = {} + total_downloaded = 0 + total_failed = 0 + + for league in leagues: + if league not in self.API_ENDPOINTS: + logger.warning(f"Skipping unknown league: {league}") + continue + + downloaded, failed = self.download_missing_logos_for_league(league, force_download) + results[league] = (downloaded, failed) + total_downloaded += downloaded + total_failed += failed + + logger.info(f"Overall logo download results: {total_downloaded} downloaded, {total_failed} failed") + return results + + def create_placeholder_logo(self, team_abbreviation: str, logo_dir: str, team_name: str = None) -> bool: + """Create a placeholder logo when real logo cannot be downloaded.""" + try: + filename = f"{self.normalize_abbreviation(team_abbreviation)}.png" + filepath = Path(logo_dir) / filename + + # Create a simple placeholder logo + logo = Image.new('RGBA', (64, 64), (100, 100, 100, 255)) # Gray background + draw = ImageDraw.Draw(logo) + + # Try to load a font, fallback to default + try: + font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) + except: + try: + font = ImageFont.load_default() + except: + font = None + + # Draw team abbreviation + text = team_abbreviation + if font: + # Center the text + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + x = (64 - text_width) // 2 + y = (64 - text_height) // 2 + draw.text((x, y), text, font=font, fill=(255, 255, 255, 255)) + else: + # Fallback without font + draw.text((16, 24), text, fill=(255, 255, 255, 255)) + + logo.save(filepath) + logger.info(f"Created placeholder logo for {team_abbreviation} at {filepath}") + return True + + except Exception as e: + logger.error(f"Failed to create placeholder logo for {team_abbreviation}: {e}") + return False + + +# Convenience function for easy integration +def download_missing_logo(team_abbreviation: str, league: str, team_name: str = None, create_placeholder: bool = True) -> bool: + """ + Convenience function to download a missing team logo. + + Args: + team_abbreviation: Team abbreviation (e.g., 'UGA', 'BAMA') + league: League identifier (e.g., 'ncaa_fb', 'nfl') + team_name: Optional team name for logging + create_placeholder: Whether to create a placeholder if download fails + + Returns: + True if logo exists or was successfully downloaded, False otherwise + """ + downloader = LogoDownloader() + + # Try to download the real logo first + success = downloader.download_missing_logo_for_team(team_abbreviation, league, team_name) + + if not success and create_placeholder: + # Create placeholder as fallback + logo_dir = downloader.get_logo_directory(league) + success = downloader.create_placeholder_logo(team_abbreviation, logo_dir, team_name) + + return success + + +def download_all_logos_for_league(league: str, force_download: bool = False) -> Tuple[int, int]: + """ + Convenience function to download all missing logos for a league. + + Args: + league: League identifier (e.g., 'ncaa_fb', 'nfl') + force_download: Whether to re-download existing logos + + Returns: + Tuple of (downloaded_count, failed_count) + """ + downloader = LogoDownloader() + return downloader.download_missing_logos_for_league(league, force_download) diff --git a/src/ncaa_fb_managers.py b/src/ncaa_fb_managers.py index 72a7999f..4c87a987 100644 --- a/src/ncaa_fb_managers.py +++ b/src/ncaa_fb_managers.py @@ -11,6 +11,7 @@ from src.display_manager import DisplayManager from src.cache_manager import CacheManager # Keep CacheManager import from src.config_manager import ConfigManager 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 @@ -358,8 +359,8 @@ class BaseNCAAFBManager: # Renamed class draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) - def _load_and_resize_logo(self, team_abbrev: str) -> Optional[Image.Image]: - """Load and resize a team logo, with caching.""" + 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] @@ -367,15 +368,22 @@ class BaseNCAAFBManager: # Renamed class self.logger.debug(f"Logo path: {logo_path}") try: - # Create placeholder if logo doesn't exist (useful for testing) + # Try to download missing logo first if not os.path.exists(logo_path): - self.logger.warning(f"Logo not found for {team_abbrev} at {logo_path}. 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}") + 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, 'ncaa_fb', 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': @@ -784,8 +792,8 @@ class NCAAFBLiveManager(BaseNCAAFBManager): # Renamed class overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) draw_overlay = ImageDraw.Draw(overlay) # Draw text elements on overlay first - home_logo = self._load_and_resize_logo(game["home_abbr"]) - away_logo = self._load_and_resize_logo(game["away_abbr"]) + home_logo = self._load_and_resize_logo(game["home_abbr"], game.get("home_team_name")) + away_logo = self._load_and_resize_logo(game["away_abbr"], game.get("away_team_name")) if not home_logo or not away_logo: self.logger.error(f"[NCAAFB] Failed to load logos for live game: {game.get('id')}") # Changed log prefix @@ -1011,8 +1019,8 @@ class NCAAFBRecentManager(BaseNCAAFBManager): # Renamed class overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) draw_overlay = ImageDraw.Draw(overlay) - home_logo = self._load_and_resize_logo(game["home_abbr"]) - away_logo = self._load_and_resize_logo(game["away_abbr"]) + home_logo = self._load_and_resize_logo(game["home_abbr"], game.get("home_team_name")) + away_logo = self._load_and_resize_logo(game["away_abbr"], game.get("away_team_name")) if not home_logo or not away_logo: self.logger.error(f"[NCAAFB Recent] Failed to load logos for game: {game.get('id')}") # Changed log prefix @@ -1286,8 +1294,8 @@ class NCAAFBUpcomingManager(BaseNCAAFBManager): # Renamed class overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) draw_overlay = ImageDraw.Draw(overlay) - home_logo = self._load_and_resize_logo(game["home_abbr"]) - away_logo = self._load_and_resize_logo(game["away_abbr"]) + home_logo = self._load_and_resize_logo(game["home_abbr"], game.get("home_team_name")) + away_logo = self._load_and_resize_logo(game["away_abbr"], game.get("away_team_name")) if not home_logo or not away_logo: self.logger.error(f"[NCAAFB Upcoming] Failed to load logos for game: {game.get('id')}") # Changed log prefix diff --git a/src/odds_ticker_manager.py b/src/odds_ticker_manager.py index 684d8cb6..42dd6692 100644 --- a/src/odds_ticker_manager.py +++ b/src/odds_ticker_manager.py @@ -11,6 +11,7 @@ from src.display_manager import DisplayManager from src.cache_manager import CacheManager from src.config_manager import ConfigManager from src.odds_manager import OddsManager +from src.logo_downloader import download_missing_logo # Import the API counter function from web interface try: @@ -125,6 +126,7 @@ class OddsTickerManager: 'nfl': { 'sport': 'football', 'league': 'nfl', + 'logo_league': 'nfl', # ESPN API league identifier for logo downloading 'logo_dir': 'assets/sports/nfl_logos', 'favorite_teams': config.get('nfl_scoreboard', {}).get('favorite_teams', []), 'enabled': config.get('nfl_scoreboard', {}).get('enabled', False) @@ -132,6 +134,7 @@ class OddsTickerManager: 'nba': { 'sport': 'basketball', 'league': 'nba', + 'logo_league': 'nba', # ESPN API league identifier for logo downloading 'logo_dir': 'assets/sports/nba_logos', 'favorite_teams': config.get('nba_scoreboard', {}).get('favorite_teams', []), 'enabled': config.get('nba_scoreboard', {}).get('enabled', False) @@ -139,6 +142,7 @@ class OddsTickerManager: 'mlb': { 'sport': 'baseball', 'league': 'mlb', + 'logo_league': 'mlb', # ESPN API league identifier for logo downloading 'logo_dir': 'assets/sports/mlb_logos', 'favorite_teams': config.get('mlb', {}).get('favorite_teams', []), 'enabled': config.get('mlb', {}).get('enabled', False) @@ -146,6 +150,7 @@ class OddsTickerManager: 'ncaa_fb': { 'sport': 'football', 'league': 'college-football', + 'logo_league': 'ncaa_fb', # ESPN API league identifier for logo downloading 'logo_dir': 'assets/sports/ncaa_fbs_logos', 'favorite_teams': config.get('ncaa_fb_scoreboard', {}).get('favorite_teams', []), 'enabled': config.get('ncaa_fb_scoreboard', {}).get('enabled', False) @@ -153,6 +158,7 @@ class OddsTickerManager: 'milb': { 'sport': 'baseball', 'league': 'milb', + 'logo_league': 'milb', # ESPN API league identifier for logo downloading (if supported) 'logo_dir': 'assets/sports/milb_logos', 'favorite_teams': config.get('milb', {}).get('favorite_teams', []), 'enabled': config.get('milb', {}).get('enabled', False) @@ -160,6 +166,7 @@ class OddsTickerManager: 'nhl': { 'sport': 'hockey', 'league': 'nhl', + 'logo_league': 'nhl', # ESPN API league identifier for logo downloading 'logo_dir': 'assets/sports/nhl_logos', 'favorite_teams': config.get('nhl_scoreboard', {}).get('favorite_teams', []), 'enabled': config.get('nhl_scoreboard', {}).get('enabled', False) @@ -167,6 +174,7 @@ class OddsTickerManager: 'ncaam_basketball': { 'sport': 'basketball', 'league': 'mens-college-basketball', + 'logo_league': 'ncaam_basketball', # ESPN API league identifier for logo downloading 'logo_dir': 'assets/sports/ncaa_fbs_logos', 'favorite_teams': config.get('ncaam_basketball_scoreboard', {}).get('favorite_teams', []), 'enabled': config.get('ncaam_basketball_scoreboard', {}).get('enabled', False) @@ -174,6 +182,7 @@ class OddsTickerManager: 'ncaa_baseball': { 'sport': 'baseball', 'league': 'college-baseball', + 'logo_league': 'ncaa_baseball', # ESPN API league identifier for logo downloading 'logo_dir': 'assets/sports/ncaa_fbs_logos', 'favorite_teams': config.get('ncaa_baseball_scoreboard', {}).get('favorite_teams', []), 'enabled': config.get('ncaa_baseball_scoreboard', {}).get('enabled', False) @@ -181,6 +190,7 @@ class OddsTickerManager: 'soccer': { 'sport': 'soccer', 'leagues': config.get('soccer_scoreboard', {}).get('leagues', []), + 'logo_league': None, # Soccer logos not supported by ESPN API 'logo_dir': 'assets/sports/soccer_logos', 'favorite_teams': config.get('soccer_scoreboard', {}).get('favorite_teams', []), 'enabled': config.get('soccer_scoreboard', {}).get('enabled', False) @@ -240,8 +250,8 @@ class OddsTickerManager: logger.error(f"Error fetching record for {team_abbr} in league {league}: {e}") return "N/A" - def _get_team_logo(self, team_abbr: str, logo_dir: str) -> Optional[Image.Image]: - """Get team logo from the configured directory.""" + def _get_team_logo(self, team_abbr: str, logo_dir: str, league: str = None, team_name: str = None) -> Optional[Image.Image]: + """Get team logo from the configured directory, downloading if missing.""" if not team_abbr or not logo_dir: logger.debug("Cannot get team logo with missing team_abbr or logo_dir") return None @@ -254,6 +264,18 @@ class OddsTickerManager: return logo else: logger.warning(f"Logo not found at path: {logo_path}") + + # Try to download the missing logo if we have league information + if league: + logger.info(f"Attempting to download missing logo for {team_abbr} in league {league}") + success = download_missing_logo(team_abbr, league, team_name) + if success: + # Try to load the downloaded logo + if os.path.exists(logo_path): + logo = Image.open(logo_path) + logger.info(f"Successfully downloaded and loaded logo for {team_abbr}") + return logo + return None except Exception as e: logger.error(f"Error loading logo for {team_abbr} from {logo_dir}: {e}") @@ -512,6 +534,7 @@ class OddsTickerManager: 'odds': odds_data if has_odds else None, 'broadcast_info': broadcast_info, 'logo_dir': league_config.get('logo_dir', f'assets/sports/{league.lower()}_logos'), + 'league': league_config.get('logo_league', league), # Use logo_league for downloading 'status': status, 'status_state': status_state, 'live_info': live_info @@ -804,9 +827,11 @@ class OddsTickerManager: vs_font = self.fonts['medium'] datetime_font = self.fonts['medium'] # Use large font for date/time - # Get team logos - home_logo = self._get_team_logo(game['home_team'], game['logo_dir']) - away_logo = self._get_team_logo(game['away_team'], game['logo_dir']) + # Get team logos (with automatic download if missing) + home_logo = self._get_team_logo(game['home_team'], game['logo_dir'], + league=game.get('league'), team_name=game.get('home_team_name')) + away_logo = self._get_team_logo(game['away_team'], game['logo_dir'], + league=game.get('league'), team_name=game.get('away_team_name')) broadcast_logo = None # Enhanced broadcast logo debugging