Files
LEDMatrix/src/logo_downloader.py
T
ChuckandClaude Opus 5 968b953a51 fix(display): pin one text layout engine, and give the 5x7 BDF face a size (#539)
* fix(display): pin one text layout engine, and give the 5x7 face a size

Two ways a font could render differently on two machines running the same
code, both found while diagnosing four plugins whose golden images passed on
the machine that generated them and failed everywhere else.

**Layout engine.** `ImageFont.truetype` picks its engine at load time: Raqm
where the host Pillow was built with libraqm, Basic otherwise. The two round
fractional glyph advances differently. `PressStart2P-Regular.ttf` at 8px has
whole-pixel advances, so they agree — which is why most of the fleet matched
everywhere and hid this. `4x6-font.ttf` at 6px does not: glyph positions drift
cumulatively along a run, and the four plugins that draw body text in it
(geochron, of-the-day, christmas-countdown, ledmatrix-weather's almanac) are
exactly the four whose goldens travelled badly.

Every core font load now goes through `src/common/font_layout.load_truetype`,
which pins the Basic engine, so a render depends on the font file and the size
and nothing else. Basic gives up complex-script shaping and kerning pairs;
neither applies to bitmap-grid faces on an LED panel. Output is unchanged on a
host without libraqm.

**Zero font height.** `DisplayManager` built the 5x7 BDF face with
`freetype.Face(path)` and never called `set_char_size`, so `face.size.height`
stayed 0 and `get_font_height()` returned 0 for it — callers stacking rows by
`prev_y + prev_height + gap` drew two lines on top of each other. The
start-up line `Calendar font size: 0 pixels` has been printing the symptom all
along. `font_manager._load_bdf_font` already called `set_char_size`, so
whether measurement worked depended on which path loaded the face.

`DisplayManager` now sets it too, and `get_font_height()` falls back to the
strike the file declares rather than returning a zero line height.

Fixes ChuckBuilds/ledmatrix-plugins#397
Refs ChuckBuilds/ledmatrix-plugins#371, #375, #378, #391

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(display): give the startup banner a rung that fits a full address at 64px

CI caught what pinning the layout engine exposed rather than caused.
`_fitting_font` walks PressStart2P then 4x6 at 6px, and "255.255.255.255" --
the widest thing the startup banner ever shows -- measures 66px at 4x6/6px
against the 62 a 64x32 panel has to give. It used to squeak in only because
the measurement depended on which layout engine the host Pillow happened to
have; with the engine pinned it does not, so the rung the worst case actually
needs is now in the ladder instead of implied: 4x6 at 5px, which measures 51.

The fallback was wrong in the same place. When nothing in the ladder fit, it
returned `self.font` -- the *widest* option, and precisely how "Initializing"
came to run off the side of a 64px panel to begin with. It returns the
narrowest face that loaded now.

test/test_initializing_screen.py: 34 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(display): name the exceptions the BDF strike read can raise

Codacy flagged the try/except/pass. It was already narrow in intent -- a
malformed strike table on the measurement path must degrade to "size unknown"
rather than take the display down -- but a bare `except Exception: pass` says
neither of those things and hides a genuinely broken font behind a silent 8px
fallback. It now catches what reading `available_sizes` can actually raise and
logs which face failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: drop logo PNGs the render harness downloaded into the worktree

These are fetched at runtime by the logo cache; they are not source, and they
rode in on a `git add -A` while I was running check_plugin.py against this
branch. Nothing in the change needs them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:40:00 -04:00

925 lines
41 KiB
Python

#!/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 re
import time
import logging
import requests
import json
from typing import Dict, List, Optional, Tuple
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from PIL.PngImagePlugin import PngInfo
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from src.common.permission_utils import (
ensure_directory_permissions,
ensure_file_permissions,
get_assets_dir_mode,
get_assets_file_mode
)
logger = logging.getLogger(__name__)
#: PNG text key stamped into a generated placeholder so a later run can tell it
#: apart from a real logo that happens to be small.
PLACEHOLDER_MARKER = "ledmatrix_placeholder"
#: Geometry of a generated placeholder, used to recognise ones written before
#: the marker existed. Those are already on users' disks and would otherwise
#: never be retried.
PLACEHOLDER_SIZE = (64, 64)
PLACEHOLDER_BG = (100, 100, 100, 255)
#: How long a placeholder is trusted before the real logo is attempted again.
#: A placeholder means the download failed, and download failures are usually
#: transient (no network at boot, ESPN blipping). Retrying every frame would
#: hammer the API from a Pi that is also driving a panel; never retrying leaves
#: the team a grey box forever, which is the bug this exists to avoid.
PLACEHOLDER_RETRY_SECONDS = 6 * 60 * 60
def is_placeholder_logo(filepath: Path) -> bool:
"""True if the file at ``filepath`` is a generated placeholder, not a logo.
Checks the marker first, then falls back to matching the placeholder's
exact geometry and background colour so files written before the marker was
introduced are still recognised.
"""
try:
with Image.open(filepath) as img:
if img.info.get(PLACEHOLDER_MARKER):
return True
if img.size != PLACEHOLDER_SIZE:
return False
return img.convert("RGBA").getpixel((0, 0)) == PLACEHOLDER_BG
except Exception:
# Unreadable file: not provably a placeholder, and the caller's own
# error handling is better placed to deal with it.
return False
def should_attempt_download(filepath: Path, force_download: bool = False) -> bool:
"""Whether a real logo is worth (re)fetching for ``filepath``.
True when nothing is there, when the caller forced it, or when what is
there is a placeholder old enough to retry. A *fresh* placeholder says a
download just failed, so retrying it immediately would hammer the API for
a result that is very unlikely to have changed.
"""
if force_download or not filepath.exists():
return True
if not is_placeholder_logo(filepath):
return False
age = placeholder_age_seconds(filepath)
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
def refresh_placeholder_timestamp(filepath: Path) -> bool:
"""Restamp a placeholder so a failed retry restarts the back-off clock.
Without this a stale placeholder stays stale: every later call sees an
expired timestamp, retries, fails, and leaves the timestamp untouched --
which is a download attempt per call, the opposite of what the back-off is
for.
"""
try:
if not is_placeholder_logo(filepath):
return False
metadata = PngInfo()
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
with Image.open(filepath) as img:
img.copy().save(filepath, "PNG", pnginfo=metadata)
return True
except Exception:
logger.debug("Could not refresh placeholder timestamp for %s", filepath,
exc_info=True)
return False
def placeholder_age_seconds(filepath: Path) -> Optional[float]:
"""Seconds since a placeholder was written, or None if unknown."""
try:
with Image.open(filepath) as img:
stamped = img.info.get(PLACEHOLDER_MARKER)
if stamped and stamped != "1":
return max(0.0, time.time() - float(stamped))
except Exception:
pass
try:
return max(0.0, time.time() - filepath.stat().st_mtime)
except OSError:
return None
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',
'ncaam': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/teams', # Alias for basketball plugin
'ncaaw_basketball': 'https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/teams',
'ncaaw': 'https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/teams', # Alias for basketball plugin
'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',
'ncaaw_hockey': 'https://site.api.espn.com/apis/site/v2/sports/hockey/womens-college-hockey/teams',
'ncaam_lacrosse': 'https://site.api.espn.com/apis/site/v2/sports/lacrosse/mens-college-lacrosse/teams',
'ncaaw_lacrosse': 'https://site.api.espn.com/apis/site/v2/sports/lacrosse/womens-college-lacrosse/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',
'soccer_ger.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/ger.1/teams',
'soccer_ita.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/ita.1/teams',
'soccer_fra.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/fra.1/teams',
'soccer_por.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/por.1/teams',
'soccer_uefa.champions': 'https://site.api.espn.com/apis/site/v2/sports/soccer/uefa.champions/teams',
'soccer_uefa.europa': 'https://site.api.espn.com/apis/site/v2/sports/soccer/uefa.europa/teams',
'soccer_usa.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/usa.1/teams'
}
# Directory mappings for different leagues
LOGO_DIRECTORIES = {
'nfl': 'assets/sports/nfl_logos',
'nba': 'assets/sports/nba_logos',
'wnba': 'assets/sports/wnba_logos',
'mlb': 'assets/sports/mlb_logos',
'nhl': 'assets/sports/nhl_logos',
# NCAA sports use same directory
'ncaa_fb': 'assets/sports/ncaa_logos',
'ncaa_fb_all': 'assets/sports/ncaa_logos',
'fcs': 'assets/sports/ncaa_logos',
'ncaam_basketball': 'assets/sports/ncaa_logos',
'ncaam': 'assets/sports/ncaa_logos', # Alias for basketball plugin
'ncaaw_basketball': 'assets/sports/ncaa_logos',
'ncaaw': 'assets/sports/ncaa_logos', # Alias for basketball plugin
'ncaa_baseball': 'assets/sports/ncaa_logos',
'ncaam_hockey': 'assets/sports/ncaa_logos',
'ncaaw_hockey': 'assets/sports/ncaa_logos',
'ncaam_lacrosse': 'assets/sports/ncaa_logos',
'ncaaw_lacrosse': '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',
'soccer_ger.1': 'assets/sports/soccer_logos',
'soccer_ita.1': 'assets/sports/soccer_logos',
'soccer_fra.1': 'assets/sports/soccer_logos',
'soccer_por.1': 'assets/sports/soccer_logos',
'soccer_uefa.champions': 'assets/sports/soccer_logos',
'soccer_uefa.europa': 'assets/sports/soccer_logos',
'soccer_usa.1': 'assets/sports/soccer_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'
}
@staticmethod
def normalize_abbreviation(abbreviation: str) -> str:
"""Normalize team abbreviation for consistent filename usage.
Public API: sports scoreboard plugins call this directly.
NOTE: LogoHelper.normalize_abbreviation (src/common/logo_helper.py)
is a deliberately different variant (strips spaces, fewer character
replacements) — keep both behaviors stable; logo filenames on
existing installs depend on them.
"""
# Handle special characters that can cause filesystem issues
normalized = abbreviation.upper()
# Replace problematic characters with safe alternatives
normalized = normalized.replace('&', 'AND')
normalized = normalized.replace('/', '_')
normalized = normalized.replace('\\', '_')
normalized = normalized.replace(':', '_')
normalized = normalized.replace('*', '_')
normalized = normalized.replace('?', '_')
normalized = normalized.replace('"', '_')
normalized = normalized.replace('<', '_')
normalized = normalized.replace('>', '_')
normalized = normalized.replace('|', '_')
return normalized
@staticmethod
def get_logo_filename_variations(abbreviation: str) -> list:
"""Get possible filename variations for a team abbreviation."""
variations = []
original = abbreviation.upper()
normalized = LogoDownloader.normalize_abbreviation(abbreviation)
# Add original and normalized versions
variations.extend([f"{original}.png", f"{normalized}.png"])
# Special handling for known cases
if original == 'TA&M':
# TA&M has a file named TA&M.png, but normalize creates TAANDM.png
variations = [f"{original}.png", f"{normalized}.png"]
return variations
# Allowlist for league names used in filesystem paths: alphanumerics, underscores, dashes only
_SAFE_LEAGUE_RE = re.compile(r'^[a-z0-9_-]+$')
def get_logo_directory(self, league: str) -> str:
"""Get the logo directory for a given league."""
directory = LogoDownloader.LOGO_DIRECTORIES.get(league)
if not directory:
# Custom soccer leagues share the same logo directory as predefined ones
if league.startswith('soccer_'):
directory = 'assets/sports/soccer_logos'
else:
# Validate league before using it in a filesystem path
if not self._SAFE_LEAGUE_RE.match(league):
logger.warning(f"Rejecting unsafe league name for directory construction: {league!r}")
raise ValueError(f"Unsafe league name: {league!r}")
directory = f'assets/sports/{league}_logos'
path = Path(directory)
if not path.is_absolute():
project_root = Path(__file__).resolve().parents[1]
path = (project_root / path).resolve()
return str(path)
def ensure_logo_directory(self, logo_dir: str | Path) -> bool:
"""Ensure the logo directory exists, create if necessary."""
path = Path(logo_dir)
try:
# Create directory with proper permissions
ensure_directory_permissions(path, get_assets_dir_mode())
# Check if we can actually write to the directory
test_file = path / '.write_test'
try:
with open(test_file, 'w') as f:
f.write('test')
test_file.unlink(missing_ok=True)
logger.debug(f"Directory {path} is writable")
return True
except PermissionError:
logger.error(f"Permission denied: Cannot write to directory {path}")
logger.error("Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh")
return False
except Exception as e:
logger.error(f"Failed to test write access to directory {path}: {e}")
return False
except Exception as e:
logger.error(f"Failed to create logo directory {path}: {e}")
return False
def download_logo(self, logo_url: str, filepath: Path, team_abbreviation: 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_abbreviation} is not an image: {content_type}")
return False
with open(filepath, 'wb') as f:
f.write(response.content)
# Verify and convert the downloaded image to RGBA format
try:
with Image.open(filepath) as img:
# Convert to RGBA to avoid PIL warnings about palette images with transparency
if img.mode in ('P', 'LA', 'L'):
# Convert palette or grayscale images to RGBA
img = img.convert('RGBA')
elif img.mode == 'RGB':
# Convert RGB to RGBA (add alpha channel)
img = img.convert('RGBA')
elif img.mode != 'RGBA':
# For any other mode, convert to RGBA
img = img.convert('RGBA')
# Save the converted image
img.save(filepath, 'PNG')
# Set proper file permissions after saving
ensure_file_permissions(filepath, get_assets_file_mode())
logger.info(f"Successfully downloaded and converted logo for {team_abbreviation} -> {filepath.name}")
return True
except Exception as e:
logger.error(f"Downloaded file for {team_abbreviation} is not a valid image or conversion failed: {e}")
try:
os.remove(filepath) # Remove invalid file
except OSError:
pass
return False
except PermissionError as e:
logger.error(f"Permission denied downloading logo for {team_abbreviation}: {e}")
logger.error("Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh")
return False
except requests.exceptions.RequestException as e:
logger.error(f"Failed to download logo for {team_abbreviation}: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error downloading logo for {team_abbreviation}: {e}")
return False
# Allowlist for the league_code segment interpolated into ESPN API URLs
_SAFE_LEAGUE_CODE_RE = re.compile(r'^[a-z0-9_-]+$')
def _resolve_api_url(self, league: str) -> Optional[str]:
"""Resolve the ESPN API teams URL for a league, with dynamic fallback for custom soccer leagues."""
api_url = self.API_ENDPOINTS.get(league)
if not api_url and league.startswith('soccer_'):
league_code = league[len('soccer_'):]
if not self._SAFE_LEAGUE_CODE_RE.match(league_code):
logger.warning(f"Rejecting unsafe league_code for ESPN URL construction: {league_code!r}")
return None
api_url = f'https://site.api.espn.com/apis/site/v2/sports/soccer/{league_code}/teams'
logger.info(f"Using dynamic ESPN endpoint for custom soccer league: {league}")
return api_url
def fetch_teams_data(self, league: str) -> Optional[Dict]:
"""Fetch team data from ESPN API for a specific league."""
api_url = self._resolve_api_url(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, params={'limit':1000},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 fetch_single_team(self, league: str, team_id: str) -> Optional[Dict]:
"""Fetch team data from ESPN API for a specific league."""
api_url = self._resolve_api_url(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 team {team_id} in {league} from ESPN API...")
response = self.session.get(f"{api_url}/{team_id}", headers=self.headers, timeout=self.request_timeout)
response.raise_for_status()
data = response.json()
logger.info(f"Successfully fetched team data for {team_id} in {league}")
return data
except requests.exceptions.RequestException as e:
logger.error(f"Error fetching team data for {team_id} in {league}: {e}")
return None
except json.JSONDecodeError as e:
logger.error(f"Error parsing JSON response for{team_id} in {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 _get_team_name_variations(self, abbreviation: str) -> List[str]:
"""Generate common variations of a team abbreviation for matching."""
variations = set()
abbr = abbreviation.upper()
variations.add(abbr)
# Add normalized version
variations.add(self.normalize_abbreviation(abbr))
# Common substitutions
substitutions = {
'&': ['AND', 'A'],
'A&M': ['TAMU', 'TA&M', 'TEXASAM'],
'STATE': ['ST', 'ST.'],
'UNIVERSITY': ['U', 'UNIV'],
'COLLEGE': ['C', 'COL'],
'TECHNICAL': ['TECH', 'T'],
'NORTHERN': ['NORTH', 'N'],
'SOUTHERN': ['SOUTH', 'S'],
'EASTERN': ['EAST', 'E'],
'WESTERN': ['WEST', 'W']
}
# Apply substitutions
for original, replacements in substitutions.items():
if original in abbr:
for replacement in replacements:
variations.add(abbr.replace(original, replacement))
variations.add(abbr.replace(original, '')) # Remove the word entirely
# Add common abbreviations for Texas A&M
if 'A&M' in abbr or 'TAMU' in abbr:
variations.update(['TAMU', 'TA&M', 'TEXASAM', 'TEXAS_A&M', 'TEXAS_AM'])
return list(variations)
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
# A placeholder does not count as existing -- it is a previous
# failure, and a bulk pass is exactly where it should get another
# chance, subject to the same back-off as everywhere else.
if not should_attempt_download(filepath, 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
# Same eligibility rule as every other download site: a stale
# placeholder is a failed download, not a logo.
if not should_attempt_download(filepath, 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, league: str, team_id: str, team_abbreviation: str, logo_path: Path) -> bool:
"""Download a specific team's logo if it's missing."""
# Ensure the logo directory exists and is writable
logo_dir = str(logo_path.parent)
if not self.ensure_logo_directory(logo_dir):
logger.error(f"Cannot download logo for {team_abbreviation}: directory {logo_dir} is not writable")
return False
# Fetch team data to find the logo URL
data = self.fetch_single_team(league, team_id)
if not data:
return False
try:
logo_url = data["team"]["logos"][0]["href"]
except KeyError:
return False
# Download the logo
success = self.download_logo(logo_url, logo_path, team_abbreviation)
if success:
time.sleep(0.1) # Small delay
return success
def download_all_missing_logos(self, leagues: List[str] | None = 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 not self._resolve_api_url(league):
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) -> bool:
"""Create a placeholder logo when real logo cannot be downloaded."""
try:
# Ensure the logo directory exists
if not self.ensure_logo_directory(logo_dir):
logger.error(f"Failed to create logo directory: {logo_dir}")
return False
filename = f"{self.normalize_abbreviation(team_abbreviation)}.png"
filepath = Path(logo_dir) / filename
# Check if we can write to the directory
try:
# Test write permissions by creating a temporary file
test_file = filepath.parent / "test_write.tmp"
test_file.touch()
test_file.unlink() # Remove the test file
except PermissionError:
logger.error(f"Permission denied: Cannot write to directory {logo_dir}")
return False
except Exception as e:
logger.error(f"Directory access error for {logo_dir}: {e}")
return False
# 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 = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
except (OSError, IOError):
try:
font = ImageFont.load_default()
except (OSError, IOError):
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))
# Stamp it so a later run can tell this apart from a real logo and
# retry the download, instead of treating the file's existence as
# proof the logo was fetched.
metadata = PngInfo()
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
logo.save(filepath, "PNG", pnginfo=metadata)
# Set proper file permissions after saving
ensure_file_permissions(filepath, get_assets_file_mode())
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
def convert_image_to_rgba(self, filepath: Path) -> bool:
"""Convert an image file to RGBA format to avoid PIL warnings."""
try:
with Image.open(filepath) as img:
if img.mode != 'RGBA':
# Convert to RGBA
converted_img = img.convert('RGBA')
converted_img.save(filepath, 'PNG')
logger.debug(f"Converted {filepath.name} from {img.mode} to RGBA")
return True
else:
logger.debug(f"{filepath.name} is already in RGBA format")
return True
except Exception as e:
logger.error(f"Failed to convert {filepath.name} to RGBA: {e}")
return False
def convert_all_logos_to_rgba(self, league: str) -> Tuple[int, int]:
"""Convert all logos in a league directory to RGBA format."""
logo_dir = Path(self.get_logo_directory(league))
if not logo_dir.exists():
logger.warning(f"Logo directory does not exist: {logo_dir}")
return 0, 0
converted_count = 0
failed_count = 0
for logo_file in logo_dir.glob("*.png"):
if self.convert_image_to_rgba(logo_file):
converted_count += 1
else:
failed_count += 1
logger.info(f"Converted {converted_count} logos to RGBA format for {league}, {failed_count} failed")
return converted_count, failed_count
# Helper function to map soccer league codes to logo downloader format
def get_soccer_league_key(league_code: str) -> str:
"""
Map soccer league codes to logo downloader format.
Args:
league_code: Soccer league code (e.g., 'eng.1', 'por.1')
Returns:
Logo downloader league key (e.g., 'soccer_eng.1', 'soccer_por.1')
"""
return f"soccer_{league_code}"
# Convenience function for easy integration
def download_missing_logo(league: str, team_id: str, team_abbreviation: str, logo_path: Path, logo_url: str | None = None, create_placeholder: bool = True) -> bool:
"""
Convenience function to download a missing team logo.
Args:
team_abbreviation: Team abbreviation (e.g., 'UGA', 'BAMA', 'TA&M')
league: League identifier (e.g., 'ncaa_fb', 'nfl')
logo_path: Full path to where the logo should be saved
logo_url: Optional direct URL to the logo
create_placeholder: Whether to create a placeholder if download fails
Returns:
True if logo exists or was successfully downloaded, False otherwise
"""
downloader = LogoDownloader()
# Use the directory from the logo_path parameter (respects config settings)
logo_path = Path(logo_path)
if not logo_path.is_absolute():
project_root = Path(__file__).resolve().parents[1]
logo_path = (project_root / logo_path).resolve()
logo_dir = str(logo_path.parent)
# Ensure the directory exists and is writable
if not downloader.ensure_logo_directory(logo_dir):
logger.error(f"Cannot download logo for {team_abbreviation}: directory {logo_dir} is not writable")
return False
# Use the exact filepath that was passed in (respects config settings)
filepath = logo_path
if filepath.exists() and not should_attempt_download(filepath):
# Either a real logo, or a placeholder too fresh to be worth retrying.
logger.debug(f"Logo already exists for {team_abbreviation} ({league})")
return True
if filepath.exists():
# A placeholder is a *failed* download wearing the real logo's
# filename. Treating it as "already exists" is what pinned a team to a
# grey box permanently after one transient failure.
logger.info(
"Logo for %s (%s) is a placeholder from a failed download; "
"retrying the real logo", team_abbreviation, league,
)
# Try to download the real logo first
logger.info(f"Attempting to download logo for {team_abbreviation} from {league}")
if logo_url:
success = downloader.download_logo(logo_url, filepath, team_abbreviation)
if success:
time.sleep(0.1) # Small delay
if not success and create_placeholder:
logger.info(f"Creating placeholder logo for {team_abbreviation}")
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir)
return success
success = downloader.download_missing_logo_for_team(league, team_id, team_abbreviation, logo_path)
if not success and create_placeholder:
logger.info(f"Creating placeholder logo for {team_abbreviation}")
# Create placeholder as fallback
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir)
if success:
logger.info(f"Successfully handled logo for {team_abbreviation}")
else:
logger.warning(f"Failed to download or create logo for {team_abbreviation}")
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)