diff --git a/plugin-repos/ufc-scoreboard/assets/sports/ufc_logos/UFC.png b/plugin-repos/ufc-scoreboard/assets/sports/ufc_logos/UFC.png new file mode 100644 index 000000000..6d0cafaed Binary files /dev/null and b/plugin-repos/ufc-scoreboard/assets/sports/ufc_logos/UFC.png differ diff --git a/plugin-repos/ufc-scoreboard/base_odds_manager.py b/plugin-repos/ufc-scoreboard/base_odds_manager.py new file mode 100644 index 000000000..d3c5c07a4 --- /dev/null +++ b/plugin-repos/ufc-scoreboard/base_odds_manager.py @@ -0,0 +1,284 @@ +""" +BaseOddsManager - Odds data fetching adapted for MMA/UFC. + +Based on LEDMatrix BaseOddsManager with MMA-specific adaptations for +homeAthleteOdds/awayAthleteOdds and separate event_id/comp_id support. + +UFC/MMA odds adaptation based on work by Alex Resnick (legoguy1000) - PR #137 +""" + +import time +import logging +import requests +import json +from datetime import datetime, timedelta, timezone +from typing import Dict, Any, Optional, List + +# 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 + + +class BaseOddsManager: + """ + Base class for odds data fetching and management. + + Provides core functionality for: + - ESPN API odds fetching + - Caching and data processing + - Error handling and timeouts + - League mapping and data extraction + - MMA athlete odds support (homeAthleteOdds/awayAthleteOdds) + """ + + def __init__(self, cache_manager, config_manager=None): + self.cache_manager = cache_manager + self.config_manager = config_manager + self.logger = logging.getLogger(__name__) + self.base_url = "https://sports.core.api.espn.com/v2/sports" + + # Configuration with defaults + self.update_interval = 3600 # 1 hour default + self.request_timeout = 30 # 30 seconds default + self.cache_ttl = 1800 # 30 minutes default + + # Load configuration if available + if config_manager: + self._load_configuration() + + def _load_configuration(self): + """Load configuration from config manager.""" + if not self.config_manager: + return + + try: + config = self.config_manager.get_config() + odds_config = config.get("base_odds_manager", {}) + + self.update_interval = odds_config.get( + "update_interval", self.update_interval + ) + self.request_timeout = odds_config.get("timeout", self.request_timeout) + self.cache_ttl = odds_config.get("cache_ttl", self.cache_ttl) + + self.logger.debug( + f"BaseOddsManager configuration loaded: " + f"update_interval={self.update_interval}s, " + f"timeout={self.request_timeout}s, " + f"cache_ttl={self.cache_ttl}s" + ) + + except Exception as e: + self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}") + + def get_odds( + self, + sport: str, + league: str, + event_id: str, + comp_id: str = None, + update_interval_seconds: int = None, + ) -> Optional[Dict[str, Any]]: + """ + Fetch odds data for a specific fight/game. + + Args: + sport: Sport name (e.g., 'mma', 'football') + league: League name (e.g., 'ufc', 'nfl') + event_id: ESPN event ID + comp_id: ESPN competition ID (for MMA where events have multiple fights). + If None, defaults to event_id. + update_interval_seconds: Override default update interval + + Returns: + Dictionary containing odds data or None if unavailable + """ + if sport is None or league is None or event_id is None: + raise ValueError("Sport, League, and event_id cannot be None") + + if comp_id is None: + comp_id = event_id + + # Use provided interval or default + interval = update_interval_seconds or self.update_interval + cache_key = f"odds_espn_{sport}_{league}_{event_id}_{comp_id}" + + # Check cache first + cached_data = self.cache_manager.get(cache_key) + + if cached_data: + if isinstance(cached_data, dict) and cached_data.get("no_odds"): + self.logger.debug(f"Cached no-odds marker for {cache_key}, skipping") + else: + self.logger.info(f"Using cached odds from ESPN for {cache_key}") + return cached_data + + self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}") + + try: + # Map league names to ESPN API format + league_mapping = { + "ufc": "ufc", + "ncaa_fb": "college-football", + "nfl": "nfl", + "nba": "nba", + "mlb": "mlb", + "nhl": "nhl", + } + + espn_league = league_mapping.get(league, league) + url = ( + f"{self.base_url}/{sport}/leagues/{espn_league}" + f"/events/{event_id}/competitions/{comp_id}/odds" + ) + self.logger.info(f"Requesting odds from URL: {url}") + + response = requests.get(url, timeout=self.request_timeout) + response.raise_for_status() + raw_data = response.json() + + # Increment API counter for odds data + increment_api_counter("odds", 1) + self.logger.debug( + f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}" + ) + + odds_data = self._extract_espn_data(raw_data) + if odds_data: + self.logger.info(f"Successfully extracted odds data: {odds_data}") + else: + self.logger.debug("No odds data available for this fight") + + if odds_data: + self.cache_manager.set(cache_key, odds_data) + self.logger.info(f"Saved odds data to cache for {cache_key}") + else: + self.logger.debug(f"No odds data available for {cache_key}") + # Cache the fact that no odds are available to avoid repeated API calls + self.cache_manager.set(cache_key, {"no_odds": True}) + + return odds_data + + except requests.exceptions.RequestException as e: + self.logger.error(f"Error fetching odds from ESPN API for {cache_key}: {e}") + except json.JSONDecodeError: + self.logger.error( + f"Error decoding JSON response from ESPN API for {cache_key}." + ) + + return self.cache_manager.get(cache_key) + + def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Extract and format odds data from ESPN API response. + + Supports both team-based odds (homeTeamOdds/awayTeamOdds) and + MMA athlete-based odds (homeAthleteOdds/awayAthleteOdds). + + Args: + data: Raw ESPN API response data + + Returns: + Formatted odds data dictionary or None + """ + self.logger.debug(f"Extracting ESPN odds data. Data keys: {list(data.keys())}") + + if "items" in data and data["items"]: + self.logger.debug(f"Found {len(data['items'])} items in odds data") + item = data["items"][0] + self.logger.debug(f"First item keys: {list(item.keys())}") + + # MMA uses homeAthleteOdds/awayAthleteOdds instead of homeTeamOdds/awayTeamOdds + home_odds = item.get("homeTeamOdds", item.get("homeAthleteOdds", {})) + away_odds = item.get("awayTeamOdds", item.get("awayAthleteOdds", {})) + + extracted_data = { + "details": item.get("details"), + "over_under": item.get("overUnder"), + "spread": item.get("spread"), + "home_team_odds": { + "money_line": home_odds.get("moneyLine"), + "spread_odds": home_odds.get("current", {}) + .get("pointSpread", {}) + .get("value"), + }, + "away_team_odds": { + "money_line": away_odds.get("moneyLine"), + "spread_odds": away_odds.get("current", {}) + .get("pointSpread", {}) + .get("value"), + }, + } + self.logger.debug( + f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}" + ) + return extracted_data + + # Check if this is a valid empty response + if ( + "count" in data + and data["count"] == 0 + and "items" in data + and data["items"] == [] + ): + self.logger.debug("Valid empty response - no odds available for this fight") + return None + + # Unexpected structure + self.logger.warning( + f"Unexpected odds data structure: {json.dumps(data, indent=2)}" + ) + return None + + def get_multiple_odds( + self, + sport: str, + league: str, + event_ids: List[str], + comp_ids: List[str] = None, + update_interval_seconds: int = None, + ) -> Dict[str, Dict[str, Any]]: + """ + Fetch odds data for multiple fights. + + Args: + sport: Sport name + league: League name + event_ids: List of ESPN event IDs + comp_ids: List of competition IDs (parallel to event_ids). If None, uses event_ids. + update_interval_seconds: Override default update interval + + Returns: + Dictionary mapping comp_id to odds data + """ + results = {} + + if comp_ids is None: + comp_ids = event_ids + + for event_id, comp_id in zip(event_ids, comp_ids): + try: + odds_data = self.get_odds( + sport, league, event_id, comp_id, update_interval_seconds + ) + if odds_data: + results[comp_id] = odds_data + except Exception as e: + self.logger.error(f"Error fetching odds for event {event_id}/{comp_id}: {e}") + continue + + return results + + def clear_cache(self, sport: str = None, league: str = None, event_id: str = None): + """Clear odds cache for specific criteria.""" + if sport and league and event_id: + cache_key = f"odds_espn_{sport}_{league}_{event_id}" + self.cache_manager.delete(cache_key) + self.logger.info(f"Cleared cache for {cache_key}") + else: + self.cache_manager.clear() + self.logger.info("Cleared all cache") diff --git a/plugin-repos/ufc-scoreboard/config_schema.json b/plugin-repos/ufc-scoreboard/config_schema.json new file mode 100644 index 000000000..7f99b486c --- /dev/null +++ b/plugin-repos/ufc-scoreboard/config_schema.json @@ -0,0 +1,570 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "UFC Scoreboard Plugin Configuration", + "description": "Configuration schema for the UFC Scoreboard plugin - displays live, recent, and upcoming UFC fights with fighter headshots, records, and odds. Based on original work by Alex Resnick (legoguy1000).", + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable or disable the UFC scoreboard plugin" + }, + "display_duration": { + "type": "number", + "default": 30, + "minimum": 5, + "maximum": 300, + "description": "Duration in seconds for the display controller to show this plugin mode before rotating to next plugin" + }, + "update_interval": { + "type": "integer", + "default": 3600, + "minimum": 30, + "maximum": 86400, + "description": "How often to fetch new data in seconds" + }, + "game_display_duration": { + "type": "number", + "default": 15, + "minimum": 3, + "maximum": 60, + "description": "Duration in seconds to show each individual fight before rotating to the next fight within the same mode" + }, + "ufc": { + "type": "object", + "title": "UFC Settings", + "description": "Configuration for UFC fights", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable UFC fights" + }, + "favorite_fighters": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of favorite fighter full names (e.g., 'Islam Makhachev', 'Jon Jones')" + }, + "favorite_weight_classes": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of favorite weight class abbreviations (e.g., 'LW', 'HW', 'WW', 'MW', 'FW', 'BW', 'FLW', 'LHW', 'WSW', 'WFW', 'WBW', 'WFLW')" + }, + "display_modes": { + "type": "object", + "title": "Display Modes", + "description": "Control which fight types to show and how they display", + "properties": { + "show_live": { + "type": "boolean", + "default": true, + "description": "Show live UFC fights" + }, + "show_recent": { + "type": "boolean", + "default": true, + "description": "Show recently completed UFC fights" + }, + "show_upcoming": { + "type": "boolean", + "default": true, + "description": "Show upcoming UFC fights" + }, + "live_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for live fights: 'switch' rotates fights one at a time, 'scroll' scrolls all fights horizontally" + }, + "recent_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for recent fights" + }, + "upcoming_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for upcoming fights" + } + } + }, + "live_priority": { + "type": "boolean", + "default": true, + "description": "Give live fights priority over other modes. When enabled, live fights will interrupt the normal mode rotation and be displayed immediately when available." + }, + "live_game_duration": { + "type": "integer", + "default": 30, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to display each live fight before rotating to the next live fight" + }, + "recent_game_duration": { + "type": "number", + "default": 15, + "description": "Duration in seconds to show each recent fight before rotating. Falls back to game_display_duration if not set." + }, + "upcoming_game_duration": { + "type": "number", + "default": 15, + "description": "Duration in seconds to show each upcoming fight before rotating. Falls back to game_display_duration if not set." + }, + "live_update_interval": { + "type": "integer", + "default": 30, + "minimum": 5, + "maximum": 300, + "description": "How often to update live fight data (seconds)" + }, + "game_limits": { + "type": "object", + "title": "Fight Limits", + "description": "Control how many fights to show", + "properties": { + "recent_games_to_show": { + "type": "integer", + "default": 5, + "minimum": 1, + "maximum": 20, + "description": "Number of recent fights to display. With favorites: N per favorite fighter. Without favorites: N total." + }, + "upcoming_games_to_show": { + "type": "integer", + "default": 5, + "minimum": 1, + "maximum": 20, + "description": "Number of upcoming fights to display. With favorites: N per favorite fighter. Without favorites: N total." + } + } + }, + "display_options": { + "type": "object", + "title": "Display Options", + "description": "Additional information to show on fight cards", + "properties": { + "show_records": { + "type": "boolean", + "default": true, + "description": "Show fighter records (e.g., 22-7-0)" + }, + "show_odds": { + "type": "boolean", + "default": true, + "description": "Show betting odds (moneyline)" + }, + "show_fighter_names": { + "type": "boolean", + "default": true, + "description": "Show fighter short names near headshots" + }, + "show_fight_class": { + "type": "boolean", + "default": true, + "description": "Show weight class abbreviation (e.g., LW, HW)" + } + } + }, + "filtering": { + "type": "object", + "title": "Filtering Options", + "description": "Control which fights are shown", + "properties": { + "show_favorite_fighters_only": { + "type": "boolean", + "default": false, + "description": "Only show fights involving favorite fighters or weight classes" + }, + "show_all_live": { + "type": "boolean", + "default": true, + "description": "Show all live fights regardless of favorites" + } + } + }, + "dynamic_duration": { + "type": "object", + "title": "Dynamic Duration Settings", + "description": "Configure dynamic duration settings for UFC fights. When enabled, the display duration adapts based on the number of available fights.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for UFC fights" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "default": 30, + "description": "Minimum total duration in seconds, even if few fights are available" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum total duration in seconds, even if many fights are available" + }, + "modes": { + "type": "object", + "title": "Per-Mode Settings", + "description": "Configure dynamic duration for specific modes", + "properties": { + "live": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for live fights" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for live mode" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum duration for live mode" + } + } + }, + "recent": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for recent fights" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for recent mode" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum duration for recent mode" + } + } + }, + "upcoming": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for upcoming fights" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "description": "Minimum duration for upcoming mode" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum duration for upcoming mode" + } + } + } + } + } + } + } + } + }, + "customization": { + "type": "object", + "title": "Display Customization", + "description": "Customize fonts and element positioning for fight card display", + "properties": { + "fighter_name_text": { + "type": "object", + "title": "Fighter Name Font", + "properties": { + "font": { + "type": "string", + "default": "assets/fonts/4x6-font.ttf", + "description": "Font file path for fighter names" + }, + "font_size": { + "type": "integer", + "default": 6, + "minimum": 4, + "maximum": 16, + "description": "Font size for fighter names" + } + }, + "additionalProperties": false + }, + "status_text": { + "type": "object", + "title": "Status/Round Text Font", + "properties": { + "font": { + "type": "string", + "default": "assets/fonts/tom-thumb.bdf", + "description": "Font file path for round/status text" + }, + "font_size": { + "type": "integer", + "default": 8, + "minimum": 4, + "maximum": 16, + "description": "Font size for round/status text" + } + }, + "additionalProperties": false + }, + "result_text": { + "type": "object", + "title": "Result Text Font", + "properties": { + "font": { + "type": "string", + "default": "assets/fonts/PressStart2P-Regular.ttf", + "description": "Font file path for fight result" + }, + "font_size": { + "type": "integer", + "default": 10, + "minimum": 6, + "maximum": 16, + "description": "Font size for fight result" + } + }, + "additionalProperties": false + }, + "detail_text": { + "type": "object", + "title": "Detail Text Font", + "properties": { + "font": { + "type": "string", + "default": "assets/fonts/4x6-font.ttf", + "description": "Font file path for detail text (odds, records, etc.)" + }, + "font_size": { + "type": "integer", + "default": 6, + "minimum": 4, + "maximum": 12, + "description": "Font size for detail text" + } + }, + "additionalProperties": false + }, + "layout": { + "type": "object", + "title": "Layout Positioning", + "description": "Adjust X,Y coordinate offsets for elements. Values are relative to default positions. Use negative values to move left/up, positive to move right/down.", + "properties": { + "fighter1_image": { + "type": "object", + "title": "Fighter 1 Headshot (Right Side)", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "fighter2_image": { + "type": "object", + "title": "Fighter 2 Headshot (Left Side)", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "status_text": { + "type": "object", + "title": "Status/Round Text", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from top (default: 0)" + } + }, + "additionalProperties": false + }, + "result_text": { + "type": "object", + "title": "Fight Result Text", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from center (default: 0)" + } + }, + "additionalProperties": false + }, + "fight_class": { + "type": "object", + "title": "Weight Class Text", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "date": { + "type": "object", + "title": "Fight Date", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "time": { + "type": "object", + "title": "Fight Time", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from date position (default: 0)" + } + }, + "additionalProperties": false + }, + "records": { + "type": "object", + "title": "Fighter Records", + "properties": { + "fighter1_x_offset": { + "type": "integer", + "default": 0, + "description": "Fighter 1 record horizontal offset from right (default: 0)" + }, + "fighter2_x_offset": { + "type": "integer", + "default": 0, + "description": "Fighter 2 record horizontal offset from left (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from bottom (default: 0)" + } + }, + "additionalProperties": false + }, + "odds": { + "type": "object", + "title": "Betting Odds", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "fighter_names": { + "type": "object", + "title": "Fighter Name Labels", + "properties": { + "fighter1_x_offset": { + "type": "integer", + "default": 0, + "description": "Fighter 1 name horizontal offset from right (default: 0)" + }, + "fighter2_x_offset": { + "type": "integer", + "default": 0, + "description": "Fighter 2 name horizontal offset from left (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from top (default: 0)" + } + }, + "additionalProperties": false + } + }, + "x-propertyOrder": ["fighter1_image", "fighter2_image", "status_text", "result_text", "fight_class", "date", "time", "records", "odds", "fighter_names"], + "additionalProperties": false + } + }, + "x-propertyOrder": ["fighter_name_text", "status_text", "result_text", "detail_text", "layout"], + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": ["enabled"] +} diff --git a/plugin-repos/ufc-scoreboard/data_sources.py b/plugin-repos/ufc-scoreboard/data_sources.py new file mode 100644 index 000000000..ac3219616 --- /dev/null +++ b/plugin-repos/ufc-scoreboard/data_sources.py @@ -0,0 +1,126 @@ +""" +Pluggable Data Source Architecture for UFC Scoreboard Plugin + +Based on original LEDMatrix data source architecture. +UFC/MMA adaptation based on work by Alex Resnick (legoguy1000) - PR #137 +""" + +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional, List +import requests +import logging +from datetime import datetime, timedelta +import time + + +class DataSource(ABC): + """Abstract base class for data sources.""" + + def __init__(self, logger: logging.Logger): + self.logger = logger + self.session = requests.Session() + + # Configure retry strategy + from requests.adapters import HTTPAdapter + from urllib3.util.retry import Retry + + retry_strategy = Retry( + total=5, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) + + @abstractmethod + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: + """Fetch live games for a sport/league.""" + pass + + @abstractmethod + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: + """Fetch schedule for a sport/league within date range.""" + pass + + @abstractmethod + def fetch_standings(self, sport: str, league: str) -> Dict: + """Fetch standings for a sport/league.""" + pass + + def get_headers(self) -> Dict[str, str]: + """Get headers for API requests.""" + return { + 'User-Agent': 'LEDMatrix/1.0', + 'Accept': 'application/json' + } + + +class ESPNDataSource(DataSource): + """ESPN API data source.""" + + def __init__(self, logger: logging.Logger): + super().__init__(logger) + self.base_url = "https://site.api.espn.com/apis/site/v2/sports" + + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: + """Fetch live games from ESPN API.""" + try: + now = datetime.now() + formatted_date = now.strftime("%Y%m%d") + url = f"{self.base_url}/{sport}/{league}/scoreboard" + response = self.session.get( + url, + params={"dates": formatted_date, "limit": 1000}, + headers=self.get_headers(), + timeout=15 + ) + response.raise_for_status() + + data = response.json() + events = data.get('events', []) + + # Filter for live games + live_events = [ + event for event in events + if event.get('competitions', [{}])[0] + .get('status', {}).get('type', {}).get('state') == 'in' + ] + + self.logger.debug(f"Fetched {len(live_events)} live games for {sport}/{league}") + return live_events + + except Exception as e: + self.logger.error(f"Error fetching live games from ESPN: {e}") + return [] + + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: + """Fetch schedule from ESPN API.""" + try: + start_date, end_date = date_range + url = f"{self.base_url}/{sport}/{league}/scoreboard" + + params = { + 'dates': f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}", + "limit": 1000 + } + + response = self.session.get( + url, headers=self.get_headers(), params=params, timeout=15 + ) + response.raise_for_status() + + data = response.json() + events = data.get('events', []) + + self.logger.debug(f"Fetched {len(events)} scheduled games for {sport}/{league}") + return events + + except Exception as e: + self.logger.error(f"Error fetching schedule from ESPN: {e}") + return [] + + def fetch_standings(self, sport: str, league: str) -> Dict: + """Fetch standings from ESPN API (not applicable for MMA, returns empty).""" + self.logger.debug(f"Standings not applicable for {sport}/{league}") + return {} diff --git a/plugin-repos/ufc-scoreboard/fight_renderer.py b/plugin-repos/ufc-scoreboard/fight_renderer.py new file mode 100644 index 000000000..26f10f06b --- /dev/null +++ b/plugin-repos/ufc-scoreboard/fight_renderer.py @@ -0,0 +1,504 @@ +""" +Fight Renderer for UFC Scoreboard Plugin + +Renders individual fight cards as PIL Images for both switch mode +(one fight at a time) and scroll mode (all fights scrolling horizontally). + +Based on GameRenderer pattern from football-scoreboard plugin. +UFC/MMA adaptation based on work by Alex Resnick (legoguy1000) - PR #137 +""" + +import logging +from pathlib import Path +from typing import Dict, Any, Optional, Union +from PIL import Image, ImageDraw, ImageFont + +logger = logging.getLogger(__name__) + + +class FightRenderer: + """ + Renders individual fight cards as PIL Images for display. + + MMA-specific rendering differences from team sports: + - Fighter headshots instead of team logos + - Fight results (KO/TKO, Submission, Decision) instead of scores + - Round + time for live fights instead of period + clock + - Weight class display + - Fighter names displayed near headshots + """ + + def __init__( + self, + display_width: int, + display_height: int, + config: Dict[str, Any], + headshot_cache: Optional[Dict[str, Image.Image]] = None, + custom_logger: Optional[logging.Logger] = None, + ): + self.display_width = display_width + self.display_height = display_height + self.config = config + self.logger = custom_logger or logger + + # Shared headshot cache for performance + self._headshot_cache = headshot_cache if headshot_cache is not None else {} + + # Load fonts + self.fonts = self._load_fonts() + + def _load_fonts(self) -> Dict[str, Union[ImageFont.FreeTypeFont, Any]]: + """Load fonts from config or use defaults.""" + fonts = {} + customization = self.config.get("customization", {}) + + fighter_name_config = customization.get("fighter_name_text", {}) + status_config = customization.get("status_text", {}) + result_config = customization.get("result_text", {}) + detail_config = customization.get("detail_text", {}) + + try: + fonts["fighter_name"] = self._load_font( + fighter_name_config, default_path="assets/fonts/4x6-font.ttf", default_size=6 + ) + fonts["status"] = self._load_font( + status_config, default_path="assets/fonts/tom-thumb.bdf", default_size=8 + ) + fonts["result"] = self._load_font( + result_config, default_path="assets/fonts/PressStart2P-Regular.ttf", default_size=10 + ) + fonts["detail"] = self._load_font( + detail_config, default_path="assets/fonts/4x6-font.ttf", default_size=6 + ) + # Additional fonts + fonts["time"] = ImageFont.truetype("assets/fonts/tom-thumb.bdf", 8) + fonts["score"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + fonts["odds"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + fonts["record"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + self.logger.debug("Successfully loaded fight renderer fonts") + except Exception as e: + self.logger.error(f"Error loading fonts: {e}, using defaults") + try: + fonts["fighter_name"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + fonts["status"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + fonts["result"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + fonts["detail"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + fonts["time"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + fonts["score"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + fonts["odds"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + fonts["record"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + except IOError: + self.logger.warning("Fonts not found, using default PIL font.") + default_font = ImageFont.load_default() + for key in ["fighter_name", "status", "result", "detail", "time", "score", "odds", "record"]: + fonts[key] = default_font + + return fonts + + def _load_font( + self, + element_config: Dict[str, Any], + default_path: str, + default_size: int, + ) -> ImageFont.FreeTypeFont: + """Load a font from config or use defaults.""" + font_path = element_config.get("font", default_path) + font_size = element_config.get("font_size", default_size) + try: + return ImageFont.truetype(font_path, font_size) + except (IOError, OSError): + self.logger.warning(f"Could not load font {font_path}, trying default") + try: + return ImageFont.truetype(default_path, default_size) + except (IOError, OSError): + return ImageFont.load_default() + + def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """Get layout offset for an element from config.""" + layout_config = self.config.get("customization", {}).get("layout", {}) + element_config = layout_config.get(element, {}) + return int(element_config.get(axis, default)) + + def _draw_text_with_outline( + self, + draw: ImageDraw.Draw, + text: str, + position: tuple, + font, + fill=(255, 255, 255), + outline_fill=(0, 0, 0), + ): + """Draw text with a black outline for readability.""" + x, y = position + # Draw outline + for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: + draw.text((x + dx, y + dy), text, font=font, fill=outline_fill) + # Draw text + draw.text((x, y), text, font=font, fill=fill) + + def _load_headshot( + self, + fighter_id: str, + fighter_name: str, + headshot_path: Path, + headshot_url: str = None, + ) -> Optional[Image.Image]: + """Load and resize a fighter headshot with caching.""" + if fighter_id in self._headshot_cache: + return self._headshot_cache[fighter_id] + + try: + if not headshot_path.exists(): + from headshot_downloader import download_missing_headshot + download_missing_headshot(fighter_id, fighter_name, headshot_path, headshot_url) + + if headshot_path.exists(): + img = Image.open(headshot_path) + if img.mode != "RGBA": + img = img.convert("RGBA") + + # Scale headshot to fit display height + max_width = int(self.display_width * 1.5) + max_height = int(self.display_height * 1.5) + img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + self._headshot_cache[fighter_id] = img + return img + else: + self.logger.error(f"Headshot not found for {fighter_name} at {headshot_path}") + return None + + except Exception as e: + self.logger.error(f"Error loading headshot for {fighter_name}: {e}", exc_info=True) + return None + + def render_fight_card( + self, + fight: Dict[str, Any], + fight_type: str = None, + display_options: Dict[str, Any] = None, + ) -> Optional[Image.Image]: + """ + Render a fight card based on fight type. + + Args: + fight: Fight data dictionary + fight_type: 'live', 'recent', or 'upcoming'. Auto-detected if None. + display_options: Display options (show_records, show_odds, etc.) + + Returns: + PIL Image of the rendered fight card, or None on error + """ + if display_options is None: + display_options = {} + + # Auto-detect fight type + if fight_type is None: + if fight.get("is_live"): + fight_type = "live" + elif fight.get("is_final"): + fight_type = "recent" + else: + fight_type = "upcoming" + + try: + if fight_type == "live": + return self._render_live_fight(fight, display_options) + elif fight_type == "recent": + return self._render_recent_fight(fight, display_options) + else: + return self._render_upcoming_fight(fight, display_options) + except Exception as e: + self.logger.error(f"Error rendering {fight_type} fight card: {e}", exc_info=True) + return None + + def _render_live_fight( + self, fight: Dict[str, Any], display_options: Dict[str, Any] + ) -> Image.Image: + """Render a live fight card.""" + main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) + overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # Load headshots + fighter1_img = self._load_headshot( + fight["fighter1_id"], fight["fighter1_name"], + fight["fighter1_image_path"], fight.get("fighter1_image_url"), + ) + fighter2_img = self._load_headshot( + fight["fighter2_id"], fight["fighter2_name"], + fight["fighter2_image_path"], fight.get("fighter2_image_url"), + ) + + center_y = self.display_height // 2 + + # Draw fighter1 headshot (right side) + if fighter1_img: + f1_x = ( + self.display_width - fighter1_img.width + fighter1_img.width // 4 + 2 + + self._get_layout_offset("fighter1_image", "x_offset") + ) + f1_y = center_y - (fighter1_img.height // 2) + self._get_layout_offset("fighter1_image", "y_offset") + main_img.paste(fighter1_img, (f1_x, f1_y), fighter1_img) + + # Draw fighter2 headshot (left side) + if fighter2_img: + f2_x = -2 - fighter2_img.width // 4 + self._get_layout_offset("fighter2_image", "x_offset") + f2_y = center_y - (fighter2_img.height // 2) + self._get_layout_offset("fighter2_image", "y_offset") + main_img.paste(fighter2_img, (f2_x, f2_y), fighter2_img) + + # Round + time (top center) + status_text = fight.get("status_text", "") + if status_text: + status_width = draw.textlength(status_text, font=self.fonts["time"]) + sx = (self.display_width - status_width) // 2 + self._get_layout_offset("status_text", "x_offset") + sy = 1 + self._get_layout_offset("status_text", "y_offset") + self._draw_text_with_outline(draw, status_text, (sx, sy), self.fonts["time"]) + + # Fight class (center bottom area) + if display_options.get("show_fight_class", True): + fight_class = fight.get("fight_class", "") + if fight_class: + fc_width = draw.textlength(fight_class, font=self.fonts["detail"]) + fc_x = (self.display_width - fc_width) // 2 + self._get_layout_offset("fight_class", "x_offset") + fc_y = self.display_height - 8 + self._get_layout_offset("fight_class", "y_offset") + self._draw_text_with_outline(draw, fight_class, (fc_x, fc_y), self.fonts["detail"]) + + # Fighter names + if display_options.get("show_fighter_names", True): + self._draw_fighter_names(draw, fight) + + # Records + if display_options.get("show_records", True): + self._draw_records(draw, fight) + + # Odds + if display_options.get("show_odds", True) and fight.get("odds"): + self._draw_odds(draw, fight["odds"]) + + # Composite + main_img = Image.alpha_composite(main_img, overlay) + return main_img.convert("RGB") + + def _render_recent_fight( + self, fight: Dict[str, Any], display_options: Dict[str, Any] + ) -> Image.Image: + """Render a recently completed fight card.""" + main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) + overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # Load headshots + fighter1_img = self._load_headshot( + fight["fighter1_id"], fight["fighter1_name"], + fight["fighter1_image_path"], fight.get("fighter1_image_url"), + ) + fighter2_img = self._load_headshot( + fight["fighter2_id"], fight["fighter2_name"], + fight["fighter2_image_path"], fight.get("fighter2_image_url"), + ) + + center_y = self.display_height // 2 + + # Draw fighter1 headshot (right side) + if fighter1_img: + f1_x = ( + self.display_width - fighter1_img.width + fighter1_img.width // 4 + 2 + + self._get_layout_offset("fighter1_image", "x_offset") + ) + f1_y = center_y - (fighter1_img.height // 2) + self._get_layout_offset("fighter1_image", "y_offset") + main_img.paste(fighter1_img, (f1_x, f1_y), fighter1_img) + + # Draw fighter2 headshot (left side) + if fighter2_img: + f2_x = -2 - fighter2_img.width // 4 + self._get_layout_offset("fighter2_image", "x_offset") + f2_y = center_y - (fighter2_img.height // 2) + self._get_layout_offset("fighter2_image", "y_offset") + main_img.paste(fighter2_img, (f2_x, f2_y), fighter2_img) + + # Status text - "Final" or result method (top center) + status_text = fight.get("status_text", "Final") + status_width = draw.textlength(status_text, font=self.fonts["time"]) + sx = (self.display_width - status_width) // 2 + self._get_layout_offset("status_text", "x_offset") + sy = 1 + self._get_layout_offset("status_text", "y_offset") + self._draw_text_with_outline(draw, status_text, (sx, sy), self.fonts["time"]) + + # Fight class (bottom center) + if display_options.get("show_fight_class", True): + fight_class = fight.get("fight_class", "") + if fight_class: + fc_width = draw.textlength(fight_class, font=self.fonts["detail"]) + fc_x = (self.display_width - fc_width) // 2 + self._get_layout_offset("fight_class", "x_offset") + fc_y = self.display_height - 8 + self._get_layout_offset("fight_class", "y_offset") + self._draw_text_with_outline(draw, fight_class, (fc_x, fc_y), self.fonts["detail"]) + + # Records + if display_options.get("show_records", True): + self._draw_records(draw, fight) + + # Odds + if display_options.get("show_odds", True) and fight.get("odds"): + self._draw_odds(draw, fight["odds"]) + + # Composite + main_img = Image.alpha_composite(main_img, overlay) + return main_img.convert("RGB") + + def _render_upcoming_fight( + self, fight: Dict[str, Any], display_options: Dict[str, Any] + ) -> Image.Image: + """Render an upcoming fight card.""" + main_img = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)) + overlay = Image.new("RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # Load headshots + fighter1_img = self._load_headshot( + fight["fighter1_id"], fight["fighter1_name"], + fight["fighter1_image_path"], fight.get("fighter1_image_url"), + ) + fighter2_img = self._load_headshot( + fight["fighter2_id"], fight["fighter2_name"], + fight["fighter2_image_path"], fight.get("fighter2_image_url"), + ) + + center_y = self.display_height // 2 + + # Draw fighter1 headshot (right side) + if fighter1_img: + f1_x = ( + self.display_width - fighter1_img.width + fighter1_img.width // 4 + 2 + + self._get_layout_offset("fighter1_image", "x_offset") + ) + f1_y = center_y - (fighter1_img.height // 2) + self._get_layout_offset("fighter1_image", "y_offset") + main_img.paste(fighter1_img, (f1_x, f1_y), fighter1_img) + + # Draw fighter2 headshot (left side) + if fighter2_img: + f2_x = -2 - fighter2_img.width // 4 + self._get_layout_offset("fighter2_image", "x_offset") + f2_y = center_y - (fighter2_img.height // 2) + self._get_layout_offset("fighter2_image", "y_offset") + main_img.paste(fighter2_img, (f2_x, f2_y), fighter2_img) + + # Fighter names near headshots + if display_options.get("show_fighter_names", True): + self._draw_fighter_names(draw, fight) + + # Date + Time (center) + game_date = fight.get("game_date", "") + game_time = fight.get("game_time", "") + if game_date: + date_width = draw.textlength(game_date, font=self.fonts["detail"]) + dx = (self.display_width - date_width) // 2 + self._get_layout_offset("date", "x_offset") + dy = 1 + self._get_layout_offset("date", "y_offset") + self._draw_text_with_outline(draw, game_date, (dx, dy), self.fonts["detail"]) + + if game_time: + time_width = draw.textlength(game_time, font=self.fonts["detail"]) + tx = (self.display_width - time_width) // 2 + self._get_layout_offset("time", "x_offset") + ty = 8 + self._get_layout_offset("time", "y_offset") + self._draw_text_with_outline(draw, game_time, (tx, ty), self.fonts["detail"]) + + # Fight class + if display_options.get("show_fight_class", True): + fight_class = fight.get("fight_class", "") + if fight_class: + fc_width = draw.textlength(fight_class, font=self.fonts["detail"]) + fc_x = (self.display_width - fc_width) // 2 + self._get_layout_offset("fight_class", "x_offset") + fc_y = self.display_height - 8 + self._get_layout_offset("fight_class", "y_offset") + self._draw_text_with_outline(draw, fight_class, (fc_x, fc_y), self.fonts["detail"]) + + # Records + if display_options.get("show_records", True): + self._draw_records(draw, fight) + + # Odds + if display_options.get("show_odds", True) and fight.get("odds"): + self._draw_odds(draw, fight["odds"]) + + # Composite + main_img = Image.alpha_composite(main_img, overlay) + return main_img.convert("RGB") + + def _draw_fighter_names(self, draw: ImageDraw.Draw, fight: Dict[str, Any]): + """Draw fighter short names near their headshots.""" + name_font = self.fonts["fighter_name"] + y_offset = self._get_layout_offset("fighter_names", "y_offset") + + # Fighter2 name (left side) + f2_name = fight.get("fighter2_name_short", "") + if f2_name: + f2_x = 1 + self._get_layout_offset("fighter_names", "fighter2_x_offset") + f2_y = 1 + y_offset + self._draw_text_with_outline(draw, f2_name, (f2_x, f2_y), name_font) + + # Fighter1 name (right side) + f1_name = fight.get("fighter1_name_short", "") + if f1_name: + f1_width = draw.textlength(f1_name, font=name_font) + f1_x = self.display_width - f1_width - 1 + self._get_layout_offset("fighter_names", "fighter1_x_offset") + f1_y = 1 + y_offset + self._draw_text_with_outline(draw, f1_name, (f1_x, f1_y), name_font) + + def _draw_records(self, draw: ImageDraw.Draw, fight: Dict[str, Any]): + """Draw fighter records at the bottom of the card.""" + record_font = self.fonts["record"] + y_offset = self._get_layout_offset("records", "y_offset") + + record_bbox = draw.textbbox((0, 0), "0-0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height + y_offset + + # Fighter2 record (left side) + f2_record = fight.get("fighter2_record", "") + if f2_record: + f2_x = 0 + self._get_layout_offset("records", "fighter2_x_offset") + self._draw_text_with_outline(draw, f2_record, (f2_x, record_y), record_font) + + # Fighter1 record (right side) + f1_record = fight.get("fighter1_record", "") + if f1_record: + f1_bbox = draw.textbbox((0, 0), f1_record, font=record_font) + f1_width = f1_bbox[2] - f1_bbox[0] + f1_x = self.display_width - f1_width + self._get_layout_offset("records", "fighter1_x_offset") + self._draw_text_with_outline(draw, f1_record, (f1_x, record_y), record_font) + + def _draw_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]): + """Draw betting odds dynamically positioned.""" + odds_font = self.fonts["odds"] + x_offset = self._get_layout_offset("odds", "x_offset") + y_offset = self._get_layout_offset("odds", "y_offset") + + # Get moneyline odds + home_ml = odds.get("home_team_odds", {}).get("money_line") + away_ml = odds.get("away_team_odds", {}).get("money_line") + + if home_ml is not None and away_ml is not None: + # Determine favored fighter + home_favored = home_ml < 0 if isinstance(home_ml, (int, float)) else False + + # Format odds text + fav_ml = home_ml if home_favored else away_ml + fav_text = f"{int(fav_ml):+d}" if isinstance(fav_ml, (int, float)) else str(fav_ml) + + # Position on the favored side + if home_favored: + # Fighter1 (right/home) is favored - draw on right + text_width = draw.textlength(fav_text, font=odds_font) + ox = self.display_width - text_width - 1 + x_offset + else: + # Fighter2 (left/away) is favored - draw on left + ox = 1 + x_offset + + oy = self.display_height // 2 - 3 + y_offset + self._draw_text_with_outline(draw, fav_text, (ox, oy), odds_font) + + elif home_ml is not None: + ml_text = f"{int(home_ml):+d}" if isinstance(home_ml, (int, float)) else str(home_ml) + text_width = draw.textlength(ml_text, font=odds_font) + ox = self.display_width - text_width - 1 + x_offset + oy = self.display_height // 2 - 3 + y_offset + self._draw_text_with_outline(draw, ml_text, (ox, oy), odds_font) + + elif away_ml is not None: + ml_text = f"{int(away_ml):+d}" if isinstance(away_ml, (int, float)) else str(away_ml) + ox = 1 + x_offset + oy = self.display_height // 2 - 3 + y_offset + self._draw_text_with_outline(draw, ml_text, (ox, oy), odds_font) diff --git a/plugin-repos/ufc-scoreboard/generate_placeholder_icon.py b/plugin-repos/ufc-scoreboard/generate_placeholder_icon.py new file mode 100644 index 000000000..faf300855 --- /dev/null +++ b/plugin-repos/ufc-scoreboard/generate_placeholder_icon.py @@ -0,0 +1,57 @@ +"""Generate a placeholder UFC separator icon for scroll display. + +Run this script once to create assets/sports/ufc_logos/UFC.png. +Replace with an official UFC octagon logo when available. +""" + +import math +from PIL import Image, ImageDraw, ImageFont + + +def create_ufc_octagon_icon(output_path: str, size: int = 64): + """Create a simple UFC octagon icon.""" + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Draw octagon + center_x, center_y = size // 2, size // 2 + radius = size // 2 - 2 + + # Calculate octagon vertices + points = [] + for i in range(8): + angle = math.pi / 8 + (i * math.pi / 4) # Start rotated for flat top + x = center_x + radius * math.cos(angle) + y = center_y + radius * math.sin(angle) + points.append((x, y)) + + # Draw filled octagon + draw.polygon(points, fill=(200, 16, 16, 230), outline=(255, 255, 255, 255)) + + # Draw "UFC" text + try: + font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + except Exception: + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14) + except Exception: + font = ImageFont.load_default() + + text = "UFC" + bbox = draw.textbbox((0, 0), text, font=font) + text_w = bbox[2] - bbox[0] + text_h = bbox[3] - bbox[1] + text_x = (size - text_w) // 2 + text_y = (size - text_h) // 2 + + # White text with black outline + for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: + draw.text((text_x + dx, text_y + dy), text, font=font, fill=(0, 0, 0, 255)) + draw.text((text_x, text_y), text, font=font, fill=(255, 255, 255, 255)) + + img.save(output_path, "PNG") + print(f"Created UFC icon at {output_path}") + + +if __name__ == "__main__": + create_ufc_octagon_icon("assets/sports/ufc_logos/UFC.png") diff --git a/plugin-repos/ufc-scoreboard/headshot_downloader.py b/plugin-repos/ufc-scoreboard/headshot_downloader.py new file mode 100644 index 000000000..c45bb46f0 --- /dev/null +++ b/plugin-repos/ufc-scoreboard/headshot_downloader.py @@ -0,0 +1,205 @@ +""" +Fighter Headshot Downloader for UFC Scoreboard Plugin + +Downloads and caches fighter headshot images from ESPN CDN. +Adapted from LEDMatrix LogoDownloader for MMA fighter headshots. + +Based on original work by Alex Resnick (legoguy1000) - PR #137 +""" + +import logging +import requests +from typing import Optional +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 HeadshotDownloader: + """Fighter headshot downloader from ESPN API.""" + + def __init__(self, request_timeout: int = 30, retry_attempts: int = 3): + """Initialize the headshot 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', + 'Accept': 'image/png,image/jpeg,image/*', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive' + } + + @staticmethod + def get_headshot_url(fighter_id: str) -> str: + """Get ESPN headshot URL for a fighter.""" + return ( + f"https://a.espncdn.com/combiner/i?img=" + f"/i/headshots/mma/players/full/{fighter_id}.png" + ) + + +def download_missing_headshot( + fighter_id: str, + fighter_name: str, + headshot_path: Path, + headshot_url: str = None +) -> bool: + """ + Download missing headshot for a fighter. + + Args: + fighter_id: ESPN fighter ID + fighter_name: Fighter's display name + headshot_path: Path where headshot should be saved + headshot_url: Optional headshot URL (constructed from fighter_id if not provided) + + Returns: + True if headshot was downloaded or placeholder created successfully + """ + try: + # Ensure directory exists and is writable + headshot_dir = headshot_path.parent + try: + headshot_dir.mkdir(parents=True, exist_ok=True) + + # Check if we can write to the directory + test_file = headshot_dir / '.write_test' + try: + test_file.touch() + test_file.unlink() + except PermissionError: + logger.error(f"Permission denied: Cannot write to directory {headshot_dir}") + return False + except PermissionError as e: + logger.error(f"Permission denied: Cannot create directory {headshot_dir}: {e}") + return False + except Exception as e: + logger.error(f"Failed to create headshot directory {headshot_dir}: {e}") + return False + + # Construct URL if not provided + if not headshot_url: + headshot_url = HeadshotDownloader.get_headshot_url(fighter_id) + + # Try to download the headshot + if headshot_url: + try: + response = requests.get(headshot_url, timeout=30) + if response.status_code == 200: + # Verify it's an image + content_type = response.headers.get('content-type', '').lower() + if any( + img_type in content_type + for img_type in ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'] + ): + with open(headshot_path, 'wb') as f: + f.write(response.content) + + # Convert to RGBA for consistency + try: + with Image.open(headshot_path) as img: + if img.mode != "RGBA": + img = img.convert("RGBA") + img.save(headshot_path, "PNG") + except Exception as e: + logger.warning( + f"Could not convert headshot for {fighter_name} to RGBA: {e}" + ) + + logger.info(f"Downloaded headshot for {fighter_name} from {headshot_url}") + return True + else: + logger.warning( + f"Downloaded content for {fighter_name} is not an image: {content_type}" + ) + except PermissionError as e: + logger.error(f"Permission denied downloading headshot for {fighter_name}: {e}") + return False + except Exception as e: + logger.error(f"Failed to download headshot for {fighter_name}: {e}") + + # If no URL or download failed, create a placeholder + return create_placeholder_headshot(fighter_name, headshot_path) + + except PermissionError as e: + logger.error(f"Permission denied for {fighter_name}: {e}") + return False + except Exception as e: + logger.error(f"Failed to download headshot for {fighter_name}: {e}") + # Try to create placeholder as fallback + try: + return create_placeholder_headshot(fighter_name, headshot_path) + except Exception: + return False + + +def create_placeholder_headshot(fighter_name: str, headshot_path: Path) -> bool: + """Create a simple placeholder headshot with fighter initials.""" + try: + # Ensure directory exists + headshot_path.parent.mkdir(parents=True, exist_ok=True) + + # Create a simple text-based placeholder + img = Image.new('RGBA', (64, 64), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Draw a circle background + draw.ellipse([4, 4, 60, 60], fill=(60, 60, 60, 200), outline=(100, 100, 100, 255)) + + # Try to load a font + try: + font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) + except Exception: + font = ImageFont.load_default() + + # Get fighter initials (first letter of first and last name) + parts = fighter_name.split() + if len(parts) >= 2: + initials = parts[0][0].upper() + parts[-1][0].upper() + elif parts: + initials = parts[0][:2].upper() + else: + initials = "??" + + # Draw initials centered + bbox = draw.textbbox((0, 0), initials, 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 white text with black outline + for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: + draw.text((x + dx, y + dy), initials, font=font, fill=(0, 0, 0)) + draw.text((x, y), initials, font=font, fill=(255, 255, 255)) + + # Save the placeholder + img.save(headshot_path, "PNG") + logger.info(f"Created placeholder headshot for {fighter_name}") + return True + + except PermissionError as e: + logger.error(f"Permission denied creating placeholder headshot for {fighter_name}: {e}") + return False + except Exception as e: + logger.error(f"Failed to create placeholder headshot for {fighter_name}: {e}") + return False diff --git a/plugin-repos/ufc-scoreboard/manager.py b/plugin-repos/ufc-scoreboard/manager.py new file mode 100644 index 000000000..2a9412295 --- /dev/null +++ b/plugin-repos/ufc-scoreboard/manager.py @@ -0,0 +1,1209 @@ +""" +UFC Scoreboard Plugin for LEDMatrix + +This plugin provides UFC/MMA scoreboard functionality by reusing +the proven sports manager architecture from LEDMatrix. + +Display Modes: +- Switch Mode: Display one fight at a time with timed transitions +- Scroll Mode: High-FPS horizontal scrolling of all fights with UFC separators + +Based on original work by Alex Resnick (legoguy1000) - PR #137 +""" + +import logging +import time +from typing import Dict, Any, Set, Optional, Tuple, List + +from PIL import ImageFont + +try: + from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode + from src.background_data_service import get_background_service +except ImportError: + BasePlugin = None + VegasDisplayMode = None + get_background_service = None + +# Import UFC manager classes +from ufc_managers import UFCLiveManager, UFCRecentManager, UFCUpcomingManager + +# Import scroll display components +try: + from scroll_display import ScrollDisplayManager + SCROLL_AVAILABLE = True +except ImportError: + ScrollDisplayManager = None + SCROLL_AVAILABLE = False + +logger = logging.getLogger(__name__) + + +class UFCScoreboardPlugin(BasePlugin if BasePlugin else object): + """ + UFC scoreboard plugin using existing MMA manager classes. + + This plugin provides UFC/MMA scoreboard functionality by + delegating to the MMA manager classes adapted from PR #137. + """ + + def __init__( + self, + plugin_id: str, + config: Dict[str, Any], + display_manager, + cache_manager, + plugin_manager, + ): + """Initialize the UFC scoreboard plugin.""" + if BasePlugin: + super().__init__( + plugin_id, config, display_manager, cache_manager, plugin_manager + ) + + self.plugin_id = plugin_id + self.config = config + self.display_manager = display_manager + self.cache_manager = cache_manager + self.plugin_manager = plugin_manager + + self.logger = logger + + # Basic configuration + self.is_enabled = config.get("enabled", True) + if hasattr(display_manager, "matrix") and display_manager.matrix is not None: + self.display_width = display_manager.matrix.width + self.display_height = display_manager.matrix.height + else: + self.display_width = getattr(display_manager, "width", 128) + self.display_height = getattr(display_manager, "height", 32) + + # UFC league configuration + self.ufc_enabled = config.get("ufc", {}).get("enabled", True) + self.logger.info(f"UFC enabled: {self.ufc_enabled}") + + # League registry (single league for UFC, but uses same pattern for consistency) + self._league_registry: Dict[str, Dict[str, Any]] = {} + + # Global settings + self.display_duration = float(config.get("display_duration", 30)) + self.game_display_duration = float(config.get("game_display_duration", 15)) + + # Live priority + self.ufc_live_priority = config.get("ufc", {}).get("live_priority", False) + + # Display mode settings + self._display_mode_settings = self._parse_display_mode_settings() + + # Initialize background service if available + self.background_service = None + if get_background_service: + try: + self.background_service = get_background_service( + self.cache_manager, max_workers=1 + ) + self.logger.info("Background service initialized") + except Exception as e: + self.logger.warning(f"Could not initialize background service: {e}") + + # Initialize managers + self._initialize_managers() + + # Initialize league registry + self._initialize_league_registry() + + # Initialize scroll display manager if available + self._scroll_manager: Optional[ScrollDisplayManager] = None + if SCROLL_AVAILABLE and ScrollDisplayManager: + try: + self._scroll_manager = ScrollDisplayManager( + self.display_manager, self.config, self.logger + ) + self.logger.info("Scroll display manager initialized") + except Exception as e: + self.logger.warning( + f"Could not initialize scroll display manager: {e}" + ) + self._scroll_manager = None + + # Track current scroll state + self._scroll_active: Dict[str, bool] = {} + self._scroll_prepared: Dict[str, bool] = {} + + # Enable high-FPS mode for scroll display + self.enable_scrolling = self._scroll_manager is not None + if self.enable_scrolling: + self.logger.info("High-FPS scrolling enabled for UFC scoreboard") + + # Mode cycling + self.current_mode_index = 0 + self.last_mode_switch = 0 + self.modes = self._get_available_modes() + + self.logger.info( + f"UFC scoreboard plugin initialized - " + f"{self.display_width}x{self.display_height}" + ) + + # Dynamic duration tracking + self._dynamic_cycle_seen_modes: Set[str] = set() + self._dynamic_mode_to_manager_key: Dict[str, str] = {} + self._dynamic_manager_progress: Dict[str, Set[str]] = {} + self._dynamic_managers_completed: Set[str] = set() + self._dynamic_cycle_complete = False + self._single_game_manager_start_times: Dict[str, float] = {} + self._game_id_start_times: Dict[str, Dict[str, float]] = {} + self._display_mode_to_managers: Dict[str, Set[str]] = {} + + # Track current display context + self._current_display_league: Optional[str] = None + self._current_display_mode_type: Optional[str] = None + + # Throttle logging + self._last_live_content_false_log: float = 0.0 + self._live_content_log_interval: float = 60.0 + + # Track display mode state + self._last_display_mode: Optional[str] = None + self._current_active_display_mode: Optional[str] = None + self._current_game_tracking: Dict[str, Dict[str, Any]] = {} + self._game_transition_log_interval: float = 1.0 + self._mode_start_time: Dict[str, float] = {} + + def _initialize_managers(self): + """Initialize UFC manager instances.""" + try: + ufc_config = self._adapt_config_for_manager("ufc") + + if self.ufc_enabled: + self.ufc_live = UFCLiveManager( + ufc_config, self.display_manager, self.cache_manager + ) + self.ufc_recent = UFCRecentManager( + ufc_config, self.display_manager, self.cache_manager + ) + self.ufc_upcoming = UFCUpcomingManager( + ufc_config, self.display_manager, self.cache_manager + ) + self.logger.info("UFC managers initialized") + + except Exception as e: + self.logger.error(f"Error initializing managers: {e}", exc_info=True) + + def _initialize_league_registry(self) -> None: + """Initialize the league registry with the UFC league.""" + self._league_registry["ufc"] = { + "enabled": self.ufc_enabled, + "priority": 1, + "live_priority": self.ufc_live_priority, + "managers": { + "live": getattr(self, "ufc_live", None), + "recent": getattr(self, "ufc_recent", None), + "upcoming": getattr(self, "ufc_upcoming", None), + }, + } + + enabled_leagues = [ + lid for lid, data in self._league_registry.items() if data["enabled"] + ] + self.logger.info( + f"League registry initialized: {len(self._league_registry)} league(s), " + f"{len(enabled_leagues)} enabled: {enabled_leagues}" + ) + + def _get_enabled_leagues_for_mode(self, mode_type: str) -> List[str]: + """Get enabled leagues for a mode type in priority order.""" + enabled_leagues = [] + + for league_id, league_data in self._league_registry.items(): + if not league_data.get("enabled", False): + continue + + league_config = self.config.get(league_id, {}) + display_modes_config = league_config.get("display_modes", {}) + + mode_enabled = True + if mode_type == "live": + mode_enabled = display_modes_config.get("show_live", True) + elif mode_type == "recent": + mode_enabled = display_modes_config.get("show_recent", True) + elif mode_type == "upcoming": + mode_enabled = display_modes_config.get("show_upcoming", True) + + if mode_enabled: + enabled_leagues.append(league_id) + + enabled_leagues.sort( + key=lambda lid: self._league_registry[lid].get("priority", 999) + ) + return enabled_leagues + + def _get_league_manager_for_mode(self, league_id: str, mode_type: str): + """Get the manager instance for a specific league and mode type.""" + if league_id not in self._league_registry: + return None + managers = self._league_registry[league_id].get("managers", {}) + return managers.get(mode_type) + + def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: + """ + Adapt plugin config format to manager expected format. + + Plugin uses: ufc: {...} + Managers expect: ufc_scoreboard: {...} + """ + league_config = self.config.get(league, {}) + + game_limits = league_config.get("game_limits", {}) + display_options = league_config.get("display_options", {}) + filtering = league_config.get("filtering", {}) + display_modes_config = league_config.get("display_modes", {}) + + manager_display_modes = { + f"{league}_live": display_modes_config.get("show_live", True), + f"{league}_recent": display_modes_config.get("show_recent", True), + f"{league}_upcoming": display_modes_config.get("show_upcoming", True), + } + + # Get favorite fighters filtering + show_favorites_only = filtering.get( + "show_favorite_fighters_only", + league_config.get("show_favorite_fighters_only", False), + ) + show_all_live = filtering.get( + "show_all_live", league_config.get("show_all_live", False) + ) + + manager_config = { + f"{league}_scoreboard": { + "enabled": league_config.get("enabled", True), + "favorite_fighters": league_config.get("favorite_fighters", []), + "favorite_weight_class": league_config.get( + "favorite_weight_classes", [] + ), + "display_modes": manager_display_modes, + "recent_games_to_show": game_limits.get("recent_games_to_show", 5), + "upcoming_games_to_show": game_limits.get( + "upcoming_games_to_show", 10 + ), + "show_records": display_options.get("show_records", True), + "show_odds": display_options.get("show_odds", False), + "show_fighter_names": display_options.get("show_fighter_names", True), + "show_fight_class": display_options.get("show_fight_class", True), + "update_interval_seconds": league_config.get( + "update_interval_seconds", 300 + ), + "live_update_interval": league_config.get("live_update_interval", 30), + "live_game_duration": league_config.get("live_game_duration", 20), + "recent_game_duration": league_config.get("recent_game_duration", 15), + "upcoming_game_duration": league_config.get( + "upcoming_game_duration", 15 + ), + "live_priority": league_config.get("live_priority", False), + "show_favorite_fighters_only": show_favorites_only, + "show_all_live": show_all_live, + "filtering": filtering, + "background_service": { + "request_timeout": 30, + "max_retries": 3, + "priority": 2, + }, + } + } + + # Add global config + timezone_str = self.config.get("timezone") + if not timezone_str and hasattr(self.cache_manager, "config_manager"): + timezone_str = self.cache_manager.config_manager.get_timezone() + if not timezone_str: + timezone_str = "UTC" + + display_config = self.config.get("display", {}) + if not display_config and hasattr(self.cache_manager, "config_manager"): + display_config = self.cache_manager.config_manager.get_display_config() + + customization_config = self.config.get("customization", {}) + + manager_config.update( + { + "timezone": timezone_str, + "display": display_config, + "customization": customization_config, + } + ) + + self.logger.debug(f"Using timezone: {timezone_str} for {league} managers") + return manager_config + + def _parse_display_mode_settings(self) -> Dict[str, Dict[str, str]]: + """Parse display mode settings from config.""" + settings = {} + league_config = self.config.get("ufc", {}) + display_modes_config = league_config.get("display_modes", {}) + + settings["ufc"] = { + "live": display_modes_config.get("live_display_mode", "switch"), + "recent": display_modes_config.get("recent_display_mode", "switch"), + "upcoming": display_modes_config.get("upcoming_display_mode", "switch"), + } + + self.logger.debug(f"Display mode settings for UFC: {settings['ufc']}") + return settings + + def _get_display_mode(self, league: str, game_type: str) -> str: + """Get the display mode for a specific league and game type.""" + return self._display_mode_settings.get(league, {}).get(game_type, "switch") + + def _should_use_scroll_mode(self, mode_type: str) -> bool: + """Check if scroll mode should be used for this game type.""" + if self.ufc_enabled and self._get_display_mode("ufc", mode_type) == "scroll": + return True + return False + + def _get_available_modes(self) -> list: + """Get list of available display modes based on config.""" + modes = [] + + if self.ufc_enabled: + ufc_config = self.config.get("ufc", {}) + display_modes = ufc_config.get("display_modes", {}) + + if display_modes.get("show_live", True): + modes.append("ufc_live") + if display_modes.get("show_recent", True): + modes.append("ufc_recent") + if display_modes.get("show_upcoming", True): + modes.append("ufc_upcoming") + + if not modes: + modes = ["ufc_live", "ufc_recent", "ufc_upcoming"] + + return modes + + def _get_current_manager(self): + """Get the current manager based on the current mode.""" + if not self.modes: + return None + + current_mode = self.modes[self.current_mode_index] + + if not self.ufc_enabled: + return None + + if current_mode == "ufc_live": + return getattr(self, "ufc_live", None) + elif current_mode == "ufc_recent": + return getattr(self, "ufc_recent", None) + elif current_mode == "ufc_upcoming": + return getattr(self, "ufc_upcoming", None) + + return None + + def _ensure_manager_updated(self, manager) -> None: + """Trigger an update when the delegated manager is stale.""" + last_update = getattr(manager, "last_update", None) + update_interval = getattr(manager, "update_interval", None) + if last_update is None or update_interval is None: + return + + interval = update_interval + no_data_interval = getattr(manager, "no_data_interval", None) + live_games = getattr(manager, "live_games", None) + if no_data_interval and not live_games: + interval = no_data_interval + + try: + if interval and time.time() - last_update >= interval: + manager.update() + except Exception as exc: + self.logger.debug(f"Auto-refresh failed for manager {manager}: {exc}") + + # ------------------------------------------------------------------------- + # Core plugin methods + # ------------------------------------------------------------------------- + + def update(self) -> None: + """Update UFC fight data.""" + if not self.is_enabled: + return + + try: + if self.ufc_enabled: + if hasattr(self, "ufc_live"): + self.ufc_live.update() + if hasattr(self, "ufc_recent"): + self.ufc_recent.update() + if hasattr(self, "ufc_upcoming"): + self.ufc_upcoming.update() + except Exception as e: + self.logger.error(f"Error updating managers: {e}") + + def display(self, display_mode: str = None, force_clear: bool = False) -> bool: + """Display UFC fights for a specific mode. + + Args: + display_mode: Mode name (e.g., 'ufc_live', 'ufc_recent', 'ufc_upcoming') + force_clear: If True, clear display before rendering + """ + if not self.is_enabled: + return False + + try: + if display_mode: + if display_mode not in self.modes: + self.logger.debug( + f"Skipping disabled mode: {display_mode} " + f"(not in available modes: {self.modes})" + ) + return False + self._current_active_display_mode = display_mode + + if display_mode: + # Parse mode: ufc_live -> league=ufc, mode_type=live + mode_type_str = self._extract_mode_type(display_mode) + if not mode_type_str: + self.logger.warning(f"Invalid display_mode: {display_mode}") + return False + + league = None + for league_id in self._league_registry.keys(): + mode_suffixes = ["_live", "_recent", "_upcoming"] + for suffix in mode_suffixes: + if display_mode == f"{league_id}{suffix}": + league = league_id + break + if league: + break + + if not league: + self.logger.warning(f"Unknown league in display_mode: {display_mode}") + return False + + if not self._league_registry.get(league, {}).get("enabled", False): + self.logger.debug(f"League {league} is disabled") + return False + + # Check if mode is enabled + league_config = self.config.get(league, {}) + display_modes_config = league_config.get("display_modes", {}) + mode_enabled = True + if mode_type_str == "live": + mode_enabled = display_modes_config.get("show_live", True) + elif mode_type_str == "recent": + mode_enabled = display_modes_config.get("show_recent", True) + elif mode_type_str == "upcoming": + mode_enabled = display_modes_config.get("show_upcoming", True) + + if not mode_enabled: + self.logger.debug( + f"Mode {mode_type_str} disabled for {league}" + ) + return False + + return self._display_league_mode(league, mode_type_str, force_clear) + else: + return self._display_internal_cycling(force_clear) + + except Exception as e: + self.logger.error(f"Error in display method: {e}") + return False + + def _display_league_mode( + self, league: str, mode_type: str, force_clear: bool + ) -> bool: + """Display a specific league/mode combination.""" + if league not in self._league_registry: + return False + + if not self._league_registry[league].get("enabled", False): + return False + + manager = self._get_league_manager_for_mode(league, mode_type) + if not manager: + self.logger.debug(f"No manager available for {league} {mode_type}") + return False + + display_mode = f"{league}_{mode_type}" + + # Set display context for dynamic duration + self._current_display_league = league + self._current_display_mode_type = mode_type + + # Try display + success, _ = self._try_manager_display( + manager, force_clear, display_mode, mode_type + ) + + if success: + if display_mode not in self._mode_start_time: + self._mode_start_time[display_mode] = time.time() + + # Check mode duration + effective_duration = self._get_effective_mode_duration( + display_mode, mode_type + ) + if effective_duration is not None: + elapsed = time.time() - self._mode_start_time[display_mode] + if elapsed >= effective_duration: + self.logger.info( + f"Mode duration expired for {display_mode}: " + f"{elapsed:.1f}s >= {effective_duration}s" + ) + self._mode_start_time[display_mode] = time.time() + return False + else: + if display_mode in self._mode_start_time: + del self._mode_start_time[display_mode] + + return success + + def _try_manager_display( + self, + manager, + force_clear: bool, + display_mode: str, + mode_type: str, + ) -> Tuple[bool, Optional[str]]: + """Try to display content from a manager.""" + if not manager: + return False, None + + self._current_display_mode_type = mode_type + self._current_display_league = "ufc" + + self._ensure_manager_updated(manager) + result = manager.display(force_clear) + + actual_mode = f"ufc_{mode_type}" if mode_type else display_mode + + # Track game transitions + manager_class_name = manager.__class__.__name__ + current_game = getattr(manager, "current_game", None) + current_game_id = None + if current_game: + current_game_id = current_game.get("id") or current_game.get("comp_id") + if not current_game_id: + f1 = current_game.get("fighter1_name", "") + f2 = current_game.get("fighter2_name", "") + if f1 and f2: + current_game_id = f"{f1}_vs_{f2}" + + game_tracking = self._current_game_tracking.get(display_mode, {}) + last_game_id = game_tracking.get("game_id") + current_time = time.time() + last_log_time = game_tracking.get("last_log_time", 0.0) + + game_changed = current_game_id and current_game_id != last_game_id + if game_changed and ( + current_time - last_log_time >= self._game_transition_log_interval + ): + if current_game: + f1 = current_game.get("fighter1_name", "?") + f2 = current_game.get("fighter2_name", "?") + self.logger.info( + f"Fight transition in {display_mode}: {f1} vs {f2}" + ) + self._current_game_tracking[display_mode] = { + "game_id": current_game_id, + "league": "ufc", + "last_log_time": current_time, + } + + if result is True or result is None: + manager_key = self._build_manager_key(actual_mode, manager) + + try: + self._record_dynamic_progress( + manager, actual_mode=actual_mode, display_mode=display_mode + ) + except Exception: + pass + + if display_mode: + self._display_mode_to_managers.setdefault(display_mode, set()).add( + manager_key + ) + self._evaluate_dynamic_cycle_completion(display_mode=display_mode) + return True, actual_mode + + return False, None + + def _display_internal_cycling(self, force_clear: bool) -> bool: + """Handle display for internal mode cycling (legacy support).""" + if not getattr(self, "_internal_cycling_warned", False): + self.logger.warning( + "Using deprecated internal mode cycling. " + "Use display(display_mode=...) instead." + ) + self._internal_cycling_warned = True + + current_time = time.time() + if ( + self.last_mode_switch > 0 + and (current_time - self.last_mode_switch) >= self.display_duration + ): + self.current_mode_index = (self.current_mode_index + 1) % len(self.modes) + self.last_mode_switch = current_time + + if self.last_mode_switch == 0: + self.last_mode_switch = current_time + + manager = self._get_current_manager() + if manager: + self._ensure_manager_updated(manager) + result = manager.display(force_clear) + return bool(result) + + return False + + # ------------------------------------------------------------------------- + # Live priority support + # ------------------------------------------------------------------------- + + def has_live_priority(self) -> bool: + if not self.is_enabled: + return False + return self.ufc_enabled and self.ufc_live_priority + + def has_live_content(self) -> bool: + if not self.is_enabled: + return False + + ufc_live = False + if self.ufc_enabled and self.ufc_live_priority and hasattr(self, "ufc_live"): + raw_live_games = getattr(self.ufc_live, "live_games", []) + + if raw_live_games: + live_games = [ + g for g in raw_live_games if not g.get("is_final", False) + ] + + if live_games: + favorite_fighters = getattr( + self.ufc_live, "favorite_fighters", [] + ) + if favorite_fighters: + # Check if any live fight involves a favorite fighter + ufc_live = any( + g.get("fighter1_name", "").lower() in favorite_fighters + or g.get("fighter2_name", "").lower() in favorite_fighters + for g in live_games + ) + else: + ufc_live = True + + self.logger.info( + f"has_live_content: UFC live_games={len(live_games)}, " + f"ufc_live={ufc_live}" + ) + + current_time = time.time() + should_log = ufc_live or ( + current_time - self._last_live_content_false_log + >= self._live_content_log_interval + ) + if should_log and not ufc_live: + self.logger.info(f"has_live_content() returning False") + self._last_live_content_false_log = current_time + + return ufc_live + + def get_live_modes(self) -> list: + """Return registered mode names that have live content.""" + if not self.is_enabled: + return [] + + live_modes = [] + if self.ufc_enabled and self.ufc_live_priority and hasattr(self, "ufc_live"): + live_games = getattr(self.ufc_live, "live_games", []) + if live_games: + active_games = [ + g for g in live_games if not g.get("is_final", False) + ] + if active_games: + live_modes.append("ufc_live") + + return live_modes + + # ------------------------------------------------------------------------- + # Dynamic duration support + # ------------------------------------------------------------------------- + + def supports_dynamic_duration(self) -> bool: + """Check if dynamic duration is enabled for the current display context.""" + if not self.is_enabled: + return False + + if not self._current_display_league or not self._current_display_mode_type: + return False + + league = self._current_display_league + mode_type = self._current_display_mode_type + + league_config = self.config.get(league, {}) + league_dynamic = league_config.get("dynamic_duration", {}) + league_modes = league_dynamic.get("modes", {}) + mode_config = league_modes.get(mode_type, {}) + + if "enabled" in mode_config: + return bool(mode_config.get("enabled", False)) + if "enabled" in league_dynamic: + return bool(league_dynamic.get("enabled", False)) + + return False + + def get_dynamic_duration_cap(self) -> Optional[float]: + """Get dynamic duration cap for the current display context.""" + if not self.is_enabled: + return None + if not self._current_display_league or not self._current_display_mode_type: + return None + + league_config = self.config.get(self._current_display_league, {}) + league_dynamic = league_config.get("dynamic_duration", {}) + league_modes = league_dynamic.get("modes", {}) + mode_config = league_modes.get(self._current_display_mode_type, {}) + + if "max_duration_seconds" in mode_config: + try: + cap = float(mode_config["max_duration_seconds"]) + if cap > 0: + return cap + except (TypeError, ValueError): + pass + + if "max_duration_seconds" in league_dynamic: + try: + cap = float(league_dynamic["max_duration_seconds"]) + if cap > 0: + return cap + except (TypeError, ValueError): + pass + + return None + + def get_dynamic_duration_floor(self) -> Optional[float]: + """Get dynamic duration minimum for the current display context.""" + if not self.is_enabled: + return None + if not self._current_display_league or not self._current_display_mode_type: + return None + + league_config = self.config.get(self._current_display_league, {}) + league_dynamic = league_config.get("dynamic_duration", {}) + league_modes = league_dynamic.get("modes", {}) + mode_config = league_modes.get(self._current_display_mode_type, {}) + + if "min_duration_seconds" in mode_config: + try: + floor = float(mode_config["min_duration_seconds"]) + if floor > 0: + return floor + except (TypeError, ValueError): + pass + + if "min_duration_seconds" in league_dynamic: + try: + floor = float(league_dynamic["min_duration_seconds"]) + if floor > 0: + return floor + except (TypeError, ValueError): + pass + + return None + + def reset_cycle_state(self) -> None: + """Reset dynamic cycle tracking.""" + if BasePlugin: + super().reset_cycle_state() + self._dynamic_cycle_seen_modes.clear() + self._dynamic_mode_to_manager_key.clear() + self._dynamic_cycle_complete = False + self.logger.debug("Dynamic cycle state reset") + + def is_cycle_complete(self) -> bool: + """Report whether the plugin has shown a full cycle of content.""" + if not self._dynamic_feature_enabled(): + return True + + # Check scroll mode completion + if self._current_active_display_mode: + mode_type = self._extract_mode_type(self._current_active_display_mode) + if ( + mode_type + and self._should_use_scroll_mode(mode_type) + and self._scroll_manager + ): + is_complete = self._scroll_manager.is_scroll_complete() + self.logger.info( + f"is_cycle_complete() [scroll]: " + f"mode={self._current_active_display_mode}, " + f"returning {is_complete}" + ) + return is_complete + + self._evaluate_dynamic_cycle_completion( + display_mode=self._current_active_display_mode + ) + return self._dynamic_cycle_complete + + def _dynamic_feature_enabled(self) -> bool: + """Return True when dynamic duration should be active.""" + if not self.is_enabled: + return False + return self.supports_dynamic_duration() + + def _get_effective_mode_duration( + self, display_mode: str, mode_type: str + ) -> Optional[float]: + """Get effective duration for a display mode.""" + cap = self.get_dynamic_duration_cap() + if cap: + return cap + + # Fallback to game_display_duration * number of games + manager = self._get_league_manager_for_mode("ufc", mode_type) + if manager: + total_games = self._get_total_games_for_manager(manager) + if total_games > 0: + game_duration = self._get_game_duration("ufc", mode_type, manager) + return total_games * game_duration + + return None + + def _get_game_duration( + self, league: str, mode_type: str, manager=None + ) -> float: + """Get per-game display duration.""" + league_config = self.config.get(league, {}) + + if mode_type == "live": + return float(league_config.get("live_game_duration", 20)) + elif mode_type == "recent": + return float(league_config.get("recent_game_duration", 15)) + elif mode_type == "upcoming": + return float(league_config.get("upcoming_game_duration", 15)) + + return float(self.game_display_duration) + + def _record_dynamic_progress( + self, current_manager, actual_mode: str = None, display_mode: str = None + ) -> None: + """Track progress through managers/games for dynamic duration.""" + if not self._dynamic_feature_enabled() or not self.modes: + self._dynamic_cycle_complete = True + return + + current_mode = actual_mode or self.modes[self.current_mode_index] + manager_key = self._build_manager_key(current_mode, current_manager) + + self._dynamic_cycle_seen_modes.add(current_mode) + self._dynamic_mode_to_manager_key[current_mode] = manager_key + + # Track game progress + current_game = getattr(current_manager, "current_game", None) + current_game_id = None + if current_game: + current_game_id = str( + current_game.get("id") + or current_game.get("comp_id") + or "unknown" + ) + + if manager_key not in self._dynamic_manager_progress: + self._dynamic_manager_progress[manager_key] = set() + + if current_game_id: + self._dynamic_manager_progress[manager_key].add(current_game_id) + + # Check completion + total_games = self._get_total_games_for_manager(current_manager) + seen_games = len(self._dynamic_manager_progress.get(manager_key, set())) + + if total_games <= 1: + # Single game - track by time + league = self._current_display_league or "ufc" + mode_type = self._current_display_mode_type or "recent" + self._track_single_game_progress( + manager_key, current_manager, league, mode_type + ) + elif seen_games >= total_games: + if manager_key not in self._dynamic_managers_completed: + self._dynamic_managers_completed.add(manager_key) + self.logger.info( + f"Manager {manager_key} completed: " + f"{seen_games}/{total_games} games shown" + ) + + def _track_single_game_progress( + self, manager_key: str, manager, league: str, mode_type: str + ) -> None: + """Track progress for a manager with a single game.""" + current_time = time.time() + + if manager_key not in self._single_game_manager_start_times: + self._single_game_manager_start_times[manager_key] = current_time + game_duration = self._get_game_duration(league, mode_type, manager) + self.logger.info( + f"Single-game manager {manager_key} first seen, " + f"will complete after {game_duration}s" + ) + else: + start_time = self._single_game_manager_start_times[manager_key] + game_duration = self._get_game_duration(league, mode_type, manager) + elapsed = current_time - start_time + if elapsed >= game_duration: + if manager_key not in self._dynamic_managers_completed: + self._dynamic_managers_completed.add(manager_key) + self.logger.info( + f"Single-game manager {manager_key} completed " + f"after {elapsed:.1f}s" + ) + if manager_key in self._single_game_manager_start_times: + del self._single_game_manager_start_times[manager_key] + + def _evaluate_dynamic_cycle_completion( + self, display_mode: str = None + ) -> None: + """Check if all managers for the current display mode have completed.""" + if not self._dynamic_feature_enabled(): + self._dynamic_cycle_complete = True + return + + if display_mode: + manager_keys = self._display_mode_to_managers.get(display_mode, set()) + if manager_keys: + all_complete = all( + mk in self._dynamic_managers_completed for mk in manager_keys + ) + if all_complete and not self._dynamic_cycle_complete: + self._dynamic_cycle_complete = True + self.logger.info( + f"Dynamic cycle complete for {display_mode}: " + f"all {len(manager_keys)} managers finished" + ) + + @staticmethod + def _build_manager_key(mode_name: str, manager) -> str: + manager_name = manager.__class__.__name__ if manager else "None" + return f"{mode_name}:{manager_name}" + + @staticmethod + def _get_total_games_for_manager(manager) -> int: + if manager is None: + return 0 + for attr in ("live_games", "games_list", "recent_games", "upcoming_games"): + value = getattr(manager, attr, None) + if isinstance(value, list): + return len(value) + return 0 + + @staticmethod + def _get_all_game_ids_for_manager(manager) -> set: + """Get all game IDs from a manager's game list.""" + if manager is None: + return set() + game_ids = set() + for attr in ("live_games", "games_list", "recent_games", "upcoming_games"): + game_list = getattr(manager, attr, None) + if isinstance(game_list, list) and game_list: + for i, game in enumerate(game_list): + game_id = game.get("id") or game.get("comp_id") + if game_id: + game_ids.add(str(game_id)) + else: + f1 = game.get("fighter1_name", "") + f2 = game.get("fighter2_name", "") + if f1 and f2: + game_ids.add(f"{f1}_vs_{f2}-{i}") + else: + game_ids.add(f"index-{i}") + break + return game_ids + + def _extract_mode_type(self, display_mode: str) -> Optional[str]: + """Extract mode type from display mode string.""" + if display_mode.endswith("_live"): + return "live" + elif display_mode.endswith("_recent"): + return "recent" + elif display_mode.endswith("_upcoming"): + return "upcoming" + return None + + # ------------------------------------------------------------------------- + # Vegas scroll mode support + # ------------------------------------------------------------------------- + + def get_vegas_content(self) -> Optional[Any]: + """ + Get content for Vegas-style continuous scroll mode. + + Returns None to let PluginAdapter auto-detect scroll_helper.cached_image. + Triggers scroll content generation if cache is empty. + """ + if hasattr(self, "_scroll_manager") and self._scroll_manager: + if not self._scroll_manager.has_cached_content(): + self.logger.info("[UFC Vegas] Triggering scroll content generation") + self._ensure_scroll_content_for_vegas() + + return None + + def get_vegas_content_type(self) -> str: + """Indicate the type of content for Vegas scroll.""" + return "multi" + + def get_vegas_display_mode(self) -> "VegasDisplayMode": + """Get the display mode for Vegas scroll integration.""" + if VegasDisplayMode: + config_mode = self.config.get("vegas_mode") + if config_mode: + try: + return VegasDisplayMode(config_mode) + except ValueError: + self.logger.warning( + f"Invalid vegas_mode '{config_mode}', using SCROLL" + ) + return VegasDisplayMode.SCROLL + return "scroll" + + def _ensure_scroll_content_for_vegas(self) -> None: + """Ensure scroll content is generated for Vegas mode.""" + if not self._scroll_manager: + return + + # Refresh managers + try: + self.update() + except Exception as e: + self.logger.debug(f"[UFC Vegas] Manager refresh failed: {e}") + + # Collect all fights + games, leagues = self._collect_fights_for_scroll() + + if not games: + self.logger.debug("[UFC Vegas] No fights available") + return + + # Count fight types + type_counts = {"live": 0, "recent": 0, "upcoming": 0} + for game in games: + if game.get("is_live"): + type_counts["live"] += 1 + elif game.get("is_final"): + type_counts["recent"] += 1 + else: + type_counts["upcoming"] += 1 + + success = self._scroll_manager.prepare_and_display( + games, "mixed", leagues + ) + + if success: + type_summary = ", ".join( + f"{count} {gtype}" + for gtype, count in type_counts.items() + if count > 0 + ) + self.logger.info( + f"[UFC Vegas] Scroll content generated: " + f"{len(games)} fights ({type_summary})" + ) + else: + self.logger.warning("[UFC Vegas] Failed to generate scroll content") + + def _collect_fights_for_scroll( + self, mode_type: Optional[str] = None + ) -> Tuple[List[Dict], List[str]]: + """ + Collect all fights from UFC managers for scroll mode. + + Args: + mode_type: Optional filter ('live', 'recent', 'upcoming'). + If None, collects all types. + + Returns: + Tuple of (fights list, list of leagues included) + """ + fights = [] + leagues = [] + + if not self.ufc_enabled: + return fights, leagues + + mode_types = [mode_type] if mode_type else ["live", "recent", "upcoming"] + + for mt in mode_types: + manager = self._get_league_manager_for_mode("ufc", mt) + if manager: + manager_fights = self._get_games_from_manager(manager, mt) + if manager_fights: + for fight in manager_fights: + fight["league"] = "ufc" + if not isinstance(fight.get("status"), dict): + fight["status"] = {} + if "state" not in fight["status"]: + state_map = { + "live": "in", + "recent": "post", + "upcoming": "pre", + } + fight["status"]["state"] = state_map.get(mt, "pre") + fights.extend(manager_fights) + self.logger.debug( + f"Collected {len(manager_fights)} UFC {mt} fights for scroll" + ) + + if fights: + leagues.append("ufc") + + self.logger.debug( + f"Total scroll fights collected: {len(fights)} from {leagues}" + ) + return fights, leagues + + def _get_games_from_manager(self, manager, mode_type: str) -> List[Dict]: + """Get games list from a manager based on mode type.""" + if mode_type == "live": + return list(getattr(manager, "live_games", []) or []) + elif mode_type == "recent": + games = getattr(manager, "games_list", None) + if games is None: + games = getattr(manager, "recent_games", []) + return list(games or []) + elif mode_type == "upcoming": + games = getattr(manager, "games_list", None) + if games is None: + games = getattr(manager, "upcoming_games", []) + return list(games or []) + return [] + + # ------------------------------------------------------------------------- + # Scroll mode display + # ------------------------------------------------------------------------- + + def display_scroll_frame(self) -> bool: + """Display the next scroll frame (called by display controller).""" + if not self._scroll_manager: + return False + return self._scroll_manager.display_scroll_frame() + + def is_scrolling(self) -> bool: + """Check if scroll mode is currently active.""" + return ( + self._scroll_manager is not None + and self._scroll_manager.has_cached_content() + ) + + # ------------------------------------------------------------------------- + # Cleanup + # ------------------------------------------------------------------------- + + def cleanup(self) -> None: + """Clean up resources.""" + try: + if hasattr(self, "background_service") and self.background_service: + pass + self.logger.info("UFC scoreboard plugin cleanup completed") + except Exception as e: + self.logger.error(f"Error during cleanup: {e}") diff --git a/plugin-repos/ufc-scoreboard/manifest.json b/plugin-repos/ufc-scoreboard/manifest.json new file mode 100644 index 000000000..c99b6fc68 --- /dev/null +++ b/plugin-repos/ufc-scoreboard/manifest.json @@ -0,0 +1,39 @@ +{ + "id": "ufc-scoreboard", + "name": "UFC Scoreboard", + "version": "1.0.0", + "author": "ChuckBuilds", + "contributors": [ + { + "name": "Alex Resnick", + "github": "legoguy1000", + "contribution": "Original UFC/MMA implementation (PR #137)" + } + ], + "class_name": "UFCScoreboardPlugin", + "entry_point": "manager.py", + "description": "Live, recent, and upcoming UFC fights with fighter headshots, records, odds, and fight results. Based on original work by Alex Resnick (legoguy1000).", + "category": "sports", + "tags": ["ufc", "mma", "fighting", "sports", "scoreboard", "live-scores"], + "icon": "fas fa-fist-raised", + "display_modes": [ + "ufc_live", + "ufc_recent", + "ufc_upcoming" + ], + "update_interval": 60, + "default_duration": 15, + "config_schema": "config_schema.json", + "versions": [ + { + "version": "1.0.0", + "ledmatrix_min": "2.0.0", + "released": "2026-02-12" + } + ], + "last_updated": "2026-02-12", + "stars": 0, + "downloads": 0, + "verified": true, + "screenshot": "" +} diff --git a/plugin-repos/ufc-scoreboard/mma.py b/plugin-repos/ufc-scoreboard/mma.py new file mode 100644 index 000000000..5346c5b2b --- /dev/null +++ b/plugin-repos/ufc-scoreboard/mma.py @@ -0,0 +1,1074 @@ +"""MMA Base Classes - Adapted from original work by Alex Resnick (legoguy1000) - PR #137""" + +import logging +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +from PIL import Image, ImageDraw, ImageFont + +from data_sources import ESPNDataSource +from sports import SportsCore, SportsLive, SportsRecent, SportsUpcoming + + +class MMA(SportsCore): + """Base class for MMA sports with common functionality.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + self.data_source = ESPNDataSource(logger) + self.sport = "mma" + self.favorite_fighters = [ + f.lower() for f in self.mode_config.get("favorite_fighters", []) + ] + self.favorite_weight_class = [ + wc.lower() for wc in self.mode_config.get("favorite_weight_class", []) + ] + + def _custom_scorebug_layout(self, game: dict, draw: ImageDraw.ImageDraw): + """No-op hook for subclasses to add custom scorebug elements.""" + pass + + def _load_and_resize_headshot( + self, fighter_id: str, fighter_name: str, image_path: Path, image_url: str + ) -> Optional[Image.Image]: + """Load and resize a fighter headshot, with caching and automatic download if missing.""" + self.logger.debug(f"Headshot path: {image_path}") + if fighter_id in self._logo_cache: + self.logger.debug(f"Using cached headshot for {fighter_name}") + return self._logo_cache[fighter_id] + + try: + if not image_path.exists(): + self.logger.info( + f"Headshot not found for {fighter_name} at {image_path}. Attempting to download." + ) + + if not self.logo_dir.exists(): + self.logo_dir.mkdir(parents=True, exist_ok=True) + + response = self.session.get(image_url, headers=self.headers, timeout=120) + response.raise_for_status() + + 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", + ] + ): + self.logger.warning( + f"Downloaded content for {fighter_name} is not an image: {content_type}" + ) + return None + + with image_path.open(mode="wb") as f: + f.write(response.content) + + # Verify and convert the downloaded image to RGBA format + try: + with Image.open(image_path) as img: + if img.mode != "RGBA": + img = img.convert("RGBA") + img.save(image_path, "PNG") + + self.logger.info( + f"Successfully downloaded and converted headshot for {fighter_name} -> {image_path.name}" + ) + except Exception as e: + self.logger.error( + f"Downloaded file for {fighter_name} is not a valid image or conversion failed: {e}" + ) + try: + image_path.unlink() + except OSError: + pass + return None + + if not image_path.exists(): + self.logger.error( + f"Headshot file still doesn't exist at {image_path} after download attempt" + ) + return None + + logo = Image.open(image_path) + if logo.mode != "RGBA": + logo = logo.convert("RGBA") + + max_width = int(self.display_width * 1.5) + max_height = int(self.display_height * 1.5) + logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + self._logo_cache[fighter_id] = logo + return logo + + except Exception as e: + self.logger.error( + f"Error loading headshot for {fighter_name}: {e}", exc_info=True + ) + return None + + def _extract_game_details(self, game_event: dict) -> Dict | None: + if not game_event: + return None + try: + competition = game_event["competitions"][0] + status = competition["status"] + competitors = competition["competitors"] + game_date_str = game_event["date"] + start_time_utc = None + try: + start_time_utc = datetime.fromisoformat( + game_date_str.replace("Z", "+00:00") + ) + except ValueError: + self.logger.warning(f"Could not parse game date: {game_date_str}") + + try: + fight_class = competition["type"]["abbreviation"] + except KeyError: + fight_class = "" + + fighter1 = next((c for c in competitors if c.get("order") == 1), None) + fighter2 = next((c for c in competitors if c.get("order") == 2), None) + + if not fighter1 or not fighter2: + self.logger.warning( + f"Could not find Fighter 1 or 2 in event: {competition.get('id')}" + ) + return None + + try: + fighter1_name = fighter1["athlete"]["fullName"] + fighter1_name_short = fighter1["athlete"]["shortName"] + except KeyError: + fighter1_name = "" + fighter1_name_short = "" + try: + fighter2_name = fighter2["athlete"]["fullName"] + fighter2_name_short = fighter2["athlete"]["shortName"] + except KeyError: + fighter2_name = "" + fighter2_name_short = "" + + # Check if this is a favorite fighter/weight class match before doing expensive logging + is_favorite_game = ( + fighter1_name.lower() in self.favorite_fighters + or fighter2_name.lower() in self.favorite_fighters + ) or fight_class.lower() in self.favorite_weight_class + + if is_favorite_game: + self.logger.debug( + f"Processing favorite fight: {competition.get('id')}" + ) + self.logger.debug( + f"Found fighters: {fighter1_name} vs {fighter2_name}, Status: {status['type']['name']}, State: {status['type']['state']}" + ) + + game_time, game_date = "", "" + if start_time_utc: + local_time = start_time_utc.astimezone(self._get_timezone()) + game_time = local_time.strftime("%I:%M%p").lstrip("0") + + use_short_date_format = self.config.get("display", {}).get( + "use_short_date_format", False + ) + if use_short_date_format: + game_date = local_time.strftime("%-m/%-d") + else: + game_date = self.display_manager.format_date_with_ordinal( + local_time + ) + + fighter1_record = ( + fighter1.get("records", [{}])[0].get("summary", "") + if fighter1.get("records") + else "" + ) + fighter2_record = ( + fighter2.get("records", [{}])[0].get("summary", "") + if fighter2.get("records") + else "" + ) + + # Don't show "0-0" records - set to blank instead + if fighter1_record in {"0-0", "0-0-0"}: + fighter1_record = "" + if fighter2_record in {"0-0", "0-0-0"}: + fighter2_record = "" + + details = { + "event_id": game_event.get("id"), + "comp_id": competition.get("id"), + "id": competition.get("id"), + "game_time": game_time, + "game_date": game_date, + "start_time_utc": start_time_utc, + "status_text": status["type"]["shortDetail"], + "is_live": status["type"]["state"] == "in", + "is_final": status["type"]["state"] == "post", + "is_upcoming": ( + status["type"]["state"] == "pre" + or status["type"]["name"].lower() + in ["scheduled", "pre-game", "status_scheduled"] + ), + "is_period_break": status["type"]["name"] == "STATUS_END_PERIOD", + "fight_class": fight_class, + "fighter1_name": fighter1_name, + "fighter1_name_short": fighter1_name_short, + "fighter1_id": fighter1["id"], + "fighter1_image_path": self.logo_dir + / Path(f"{fighter1.get('id')}.png"), + "fighter1_image_url": f"https://a.espncdn.com/combiner/i?img=/i/headshots/mma/players/full/{fighter1.get('id')}.png", + "fighter1_country_url": fighter1.get("athlete", {}) + .get("flag", {}) + .get("href", ""), + "fighter1_record": fighter1_record, + "fighter2_name": fighter2_name, + "fighter2_name_short": fighter2_name_short, + "fighter2_id": fighter2["id"], + "fighter2_image_path": self.logo_dir + / Path(f"{fighter2.get('id')}.png"), + "fighter2_image_url": f"https://a.espncdn.com/combiner/i?img=/i/headshots/mma/players/full/{fighter2.get('id')}.png", + "fighter2_country_url": fighter2.get("athlete", {}) + .get("flag", {}) + .get("href", ""), + "fighter2_record": fighter2_record, + "is_within_window": True, + } + return details + except Exception as e: + self.logger.error( + f"Error extracting game details: {e} from event: {game_event.get('id')}", + exc_info=True, + ) + return None + + +class MMARecent(MMA, SportsRecent): + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the layout for a recently completed MMA fight.""" + try: + main_img = Image.new( + "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255) + ) + overlay = Image.new( + "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0) + ) + draw_overlay = ImageDraw.Draw(overlay) + + fighter1_image = self._load_and_resize_headshot( + game["fighter1_id"], + game["fighter1_name"], + game["fighter1_image_path"], + game["fighter1_image_url"], + ) + fighter2_image = self._load_and_resize_headshot( + game["fighter2_id"], + game["fighter2_name"], + game["fighter2_image_path"], + game["fighter2_image_url"], + ) + + if not fighter1_image or not fighter2_image: + self.logger.error( + f"Failed to load headshots for fight: {game.get('id')}" + ) + draw_final = ImageDraw.Draw(main_img.convert("RGB")) + self._draw_text_with_outline( + draw_final, "Image Error", (5, 5), self.fonts["status"] + ) + self.display_manager.image.paste(main_img.convert("RGB"), (0, 0)) + self.display_manager.update_display() + return + + center_y = self.display_height // 2 + + # Fighter 1 (right side) headshot position + home_x = ( + self.display_width + - fighter1_image.width + + fighter1_image.width // 4 + + 2 + + self._get_layout_offset("home_logo", "x_offset") + ) + home_y = center_y - (fighter1_image.height // 2) + self._get_layout_offset("home_logo", "y_offset") + main_img.paste(fighter1_image, (home_x, home_y), fighter1_image) + + # Fighter 2 (left side) headshot position + away_x = -2 - fighter2_image.width // 4 + self._get_layout_offset("away_logo", "x_offset") + away_y = center_y - (fighter2_image.height // 2) + self._get_layout_offset("away_logo", "y_offset") + main_img.paste(fighter2_image, (away_x, away_y), fighter2_image) + + # Result text (centered bottom) + score_text = game.get("status_text", "Final") + score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) + score_x = (self.display_width - score_width) // 2 + self._get_layout_offset("score", "x_offset") + score_y = self.display_height - 14 + self._get_layout_offset("score", "y_offset") + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + ) + + # "Final" text (top center) + status_text = game.get("period_text", "Final") + status_width = draw_overlay.textlength(status_text, font=self.fonts["time"]) + status_x = (self.display_width - status_width) // 2 + self._get_layout_offset("status_text", "x_offset") + status_y = 1 + self._get_layout_offset("status_text", "y_offset") + self._draw_text_with_outline( + draw_overlay, status_text, (status_x, status_y), self.fonts["time"] + ) + + if "odds" in game and game["odds"]: + self._draw_dynamic_odds( + draw_overlay, game["odds"], self.display_width, self.display_height + ) + + # Draw records if enabled + if self.show_records: + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + self.logger.debug("Loaded 6px record font successfully") + except IOError: + record_font = ImageFont.load_default() + self.logger.warning( + f"Failed to load 6px font, using default font (size: {record_font.size})" + ) + + fighter1_record = game.get("fighter1_record", "") + fighter2_record = game.get("fighter2_record", "") + + record_bbox = draw_overlay.textbbox((0, 0), "0-0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height + self.logger.debug( + f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" + ) + + # Display fighter 2 record (left side) + if fighter2_record: + fighter2_text = fighter2_record + fighter2_record_x = 0 + self.logger.debug( + f"Drawing fighter2 record '{fighter2_text}' at ({fighter2_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, fighter2_text, (fighter2_record_x, record_y), record_font + ) + + # Display fighter 1 record (right side) + if fighter1_record: + fighter1_text = fighter1_record + fighter1_record_bbox = draw_overlay.textbbox( + (0, 0), fighter1_text, font=record_font + ) + fighter1_record_width = fighter1_record_bbox[2] - fighter1_record_bbox[0] + fighter1_record_x = self.display_width - fighter1_record_width + self.logger.debug( + f"Drawing fighter1 record '{fighter1_text}' at ({fighter1_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, fighter1_text, (fighter1_record_x, record_y), record_font + ) + + self._custom_scorebug_layout(game, draw_overlay) + + # Composite and display + main_img = Image.alpha_composite(main_img, overlay) + main_img = main_img.convert("RGB") + self.display_manager.image.paste(main_img, (0, 0)) + self.display_manager.update_display() + + except Exception as e: + self.logger.error( + f"Error displaying recent fight: {e}", exc_info=True + ) + + def update(self): + """Update recent games data.""" + if not self.is_enabled: + return + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + self.last_update = current_time + + try: + data = self._fetch_data() + if not data or "events" not in data: + self.logger.warning("No events found in shared data.") + if not self.games_list: + self.current_game = None + return + + events = data["events"] + self.logger.info(f"Processing {len(events)} events from shared data.") + + # Define date range for "recent" fights (last 21 days) + now = datetime.now(timezone.utc) + recent_cutoff = now - timedelta(days=21) + self.logger.info( + f"Current time: {now}, Recent cutoff: {recent_cutoff} (21 days ago)" + ) + + # Process games and filter for final fights within date range + processed_games = [] + flattened_events = [ + { + **{k: v for k, v in event.items() if k != "competitions"}, + "competitions": [comp], + } + for event in data["events"] + for comp in event.get("competitions", []) + ] + for event in flattened_events: + game = self._extract_game_details(event) + if game and game["is_final"]: + game_time = game.get("start_time_utc") + if game_time and game_time >= recent_cutoff: + processed_games.append(game) + + # Filter for favorite fighters or weight classes + if self.favorite_fighters or self.favorite_weight_class: + favorite_team_games = [ + game + for game in processed_games + if ( + game["fighter1_name"].lower() in self.favorite_fighters + or game["fighter2_name"].lower() in self.favorite_fighters + ) + or game["fight_class"].lower() in self.favorite_weight_class + ] + self.logger.info( + f"Found {len(favorite_team_games)} favorite fighter games out of {len(processed_games)} total final games within last 21 days" + ) + + # Sort by game time (most recent first) + favorite_team_games.sort( + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + + # Select one fight per favorite fighter (most recent for each) + team_games = [] + for fighter in self.favorite_fighters: + team_specific_games = [ + game + for game in favorite_team_games + if ( + game["fighter1_name"].lower() == fighter.lower() + or game["fighter2_name"].lower() == fighter.lower() + ) + ] + + if team_specific_games: + team_specific_games.sort( + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + team_games.append(team_specific_games[0]) + + for wc in self.favorite_weight_class: + team_specific_games = [ + game + for game in favorite_team_games + if game["fight_class"].lower() == wc.lower() + ] + + if team_specific_games: + team_specific_games.sort( + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + team_games.append(team_specific_games[0]) + + # Deduplicate by converting to set of ids and back + seen_ids = set() + unique_team_games = [] + for game in team_games: + if game["id"] not in seen_ids: + seen_ids.add(game["id"]) + unique_team_games.append(game) + team_games = unique_team_games + + for i, game in enumerate(team_games): + self.logger.info( + f"Fight {i+1} for display: {game['fighter2_name']} vs {game['fighter1_name']} - {game.get('start_time_utc')}" + ) + else: + team_games = processed_games + self.logger.info( + f"Found {len(processed_games)} total final games within last 21 days (no favorite fighters configured)" + ) + team_games.sort( + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + team_games = team_games[: self.recent_games_to_show] + + # Check if the list of games to display has changed + new_game_ids = {g["id"] for g in team_games} + current_game_ids = {g["id"] for g in self.games_list} + + if new_game_ids != current_game_ids: + self.logger.info( + f"Found {len(team_games)} final fights within window for display." + ) + self.games_list = team_games + if ( + not self.current_game + or not self.games_list + or self.current_game["id"] not in new_game_ids + ): + self.current_game_index = 0 + self.current_game = self.games_list[0] if self.games_list else None + self.last_game_switch = current_time + else: + try: + self.current_game_index = next( + i + for i, g in enumerate(self.games_list) + if g["id"] == self.current_game["id"] + ) + self.current_game = self.games_list[self.current_game_index] + except StopIteration: + self.current_game_index = 0 + self.current_game = self.games_list[0] + self.last_game_switch = current_time + + elif self.games_list: + self.current_game = self.games_list[self.current_game_index] + + if not self.games_list: + self.logger.info("No relevant recent fights found to display.") + self.current_game = None + + except Exception as e: + self.logger.error( + f"Error updating recent fights: {e}", exc_info=True + ) + + +class MMAUpcoming(MMA, SportsUpcoming): + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the layout for an upcoming MMA fight.""" + try: + main_img = Image.new( + "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255) + ) + overlay = Image.new( + "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0) + ) + draw_overlay = ImageDraw.Draw(overlay) + + fighter1_image = self._load_and_resize_headshot( + game["fighter1_id"], + game["fighter1_name"], + game["fighter1_image_path"], + game["fighter1_image_url"], + ) + fighter2_image = self._load_and_resize_headshot( + game["fighter2_id"], + game["fighter2_name"], + game["fighter2_image_path"], + game["fighter2_image_url"], + ) + + if not fighter1_image or not fighter2_image: + self.logger.error( + f"Failed to load headshots for fight: {game.get('id')}" + ) + draw_final = ImageDraw.Draw(main_img.convert("RGB")) + self._draw_text_with_outline( + draw_final, "Image Error", (5, 5), self.fonts["status"] + ) + self.display_manager.image.paste(main_img.convert("RGB"), (0, 0)) + self.display_manager.update_display() + return + + center_y = self.display_height // 2 + + # Fighter 1 (right side) headshot position + home_x = ( + self.display_width + - fighter1_image.width + + fighter1_image.width // 4 + + 2 + + self._get_layout_offset("home_logo", "x_offset") + ) + home_y = center_y - (fighter1_image.height // 2) + self._get_layout_offset("home_logo", "y_offset") + main_img.paste(fighter1_image, (home_x, home_y), fighter1_image) + + # Fighter 1 short name (top left) + fighter1_name_text = game.get("fighter1_name_short", "") + status_x = 1 + self._get_layout_offset("status_text", "x_offset") + status_y = 1 + self._get_layout_offset("status_text", "y_offset") + self._draw_text_with_outline( + draw_overlay, fighter1_name_text, (status_x, status_y), self.fonts["odds"] + ) + + # Fighter 2 (left side) headshot position + away_x = -2 - fighter2_image.width // 4 + self._get_layout_offset("away_logo", "x_offset") + away_y = center_y - (fighter2_image.height // 2) + self._get_layout_offset("away_logo", "y_offset") + main_img.paste(fighter2_image, (away_x, away_y), fighter2_image) + + # Fighter 2 short name (top right) + fighter2_name_text = game.get("fighter2_name_short", "") + fighter2_name_width = draw_overlay.textlength( + fighter2_name_text, font=self.fonts["odds"] + ) + fighter2_name_x = self.display_width - fighter2_name_width - 1 + fighter2_name_y = 1 + self._draw_text_with_outline( + draw_overlay, fighter2_name_text, (fighter2_name_x, fighter2_name_y), self.fonts["odds"] + ) + + # Date and time display (centered bottom) + game_date = game.get("game_date", "") + game_time = game.get("game_time", "") + if game_date and game_time: + score_text = f"{game_date} {game_time}" + elif game_time: + score_text = game_time + elif game_date: + score_text = game_date + else: + score_text = game.get("status_text", "TBD") + + score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) + score_x = (self.display_width - score_width) // 2 + self._get_layout_offset("score", "x_offset") + score_y = self.display_height - 14 + self._get_layout_offset("score", "y_offset") + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + ) + + # Fight class / status text (top center) + status_text = game.get("fight_class", game.get("status_text", "")) + if status_text: + status_width = draw_overlay.textlength(status_text, font=self.fonts["time"]) + status_center_x = (self.display_width - status_width) // 2 + status_center_y = 1 + self._draw_text_with_outline( + draw_overlay, status_text, (status_center_x, status_center_y), self.fonts["time"] + ) + + if "odds" in game and game["odds"]: + self._draw_dynamic_odds( + draw_overlay, game["odds"], self.display_width, self.display_height + ) + + # Draw records if enabled + if self.show_records: + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + self.logger.debug("Loaded 6px record font successfully") + except IOError: + record_font = ImageFont.load_default() + self.logger.warning( + f"Failed to load 6px font, using default font (size: {record_font.size})" + ) + + fighter1_record = game.get("fighter1_record", "") + fighter2_record = game.get("fighter2_record", "") + + record_bbox = draw_overlay.textbbox((0, 0), "0-0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height + self.logger.debug( + f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" + ) + + # Display fighter 2 record (left side) + if fighter2_record: + fighter2_text = fighter2_record + fighter2_record_x = 0 + self.logger.debug( + f"Drawing fighter2 record '{fighter2_text}' at ({fighter2_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, fighter2_text, (fighter2_record_x, record_y), record_font + ) + + # Display fighter 1 record (right side) + if fighter1_record: + fighter1_text = fighter1_record + fighter1_record_bbox = draw_overlay.textbbox( + (0, 0), fighter1_text, font=record_font + ) + fighter1_record_width = fighter1_record_bbox[2] - fighter1_record_bbox[0] + fighter1_record_x = self.display_width - fighter1_record_width + self.logger.debug( + f"Drawing fighter1 record '{fighter1_text}' at ({fighter1_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, fighter1_text, (fighter1_record_x, record_y), record_font + ) + + self._custom_scorebug_layout(game, draw_overlay) + + # Composite and display + main_img = Image.alpha_composite(main_img, overlay) + main_img = main_img.convert("RGB") + self.display_manager.image.paste(main_img, (0, 0)) + self.display_manager.update_display() + + except Exception as e: + self.logger.error( + f"Error displaying upcoming fight: {e}", exc_info=True + ) + + def update(self): + """Update upcoming games data.""" + if not self.is_enabled: + return + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + self.last_update = current_time + + try: + data = self._fetch_data() + if not data or "events" not in data: + self.logger.warning("No events found in shared data.") + if not self.games_list: + self.current_game = None + return + + events = data["events"] + self.logger.info(f"Processing {len(events)} events from shared data.") + + processed_games = [] + all_upcoming_games = 0 + favorite_games_found = 0 + flattened_events = [ + { + **{k: v for k, v in event.items() if k != "competitions"}, + "competitions": [comp], + } + for event in data["events"] + for comp in event.get("competitions", []) + ] + for event in flattened_events: + game = self._extract_game_details(event) + if game and game["is_upcoming"]: + all_upcoming_games += 1 + if game and game["is_upcoming"]: + if self.show_favorite_teams_only and ( + len(self.favorite_fighters) > 0 + or len(self.favorite_weight_class) > 0 + ): + if ( + not ( + game["fighter1_name"].lower() in self.favorite_fighters + or game["fighter2_name"].lower() + in self.favorite_fighters + ) + and not game["fight_class"].lower() + in self.favorite_weight_class + ): + continue + else: + favorite_games_found += 1 + if self.show_odds: + self._fetch_odds(game) + processed_games.append(game) + + self.logger.info(f"Found {all_upcoming_games} total upcoming fights in data") + self.logger.info( + f"Found {len(processed_games)} upcoming fights after filtering" + ) + + if processed_games: + for game in processed_games[:3]: + self.logger.info( + f" {game['fighter1_name']} vs {game['fighter2_name']} - {game['start_time_utc']}" + ) + + team_games = processed_games + team_games.sort( + key=lambda g: g.get("start_time_utc") + or datetime.max.replace(tzinfo=timezone.utc) + ) + team_games = team_games[: self.upcoming_games_to_show] + + should_log = ( + current_time - self.last_log_time >= self.log_interval + or len(team_games) != len(self.games_list) + or any( + g1["id"] != g2.get("id") + for g1, g2 in zip(self.games_list, team_games) + ) + or (not self.games_list and team_games) + ) + + new_game_ids = {g["id"] for g in team_games} + current_game_ids = {g["id"] for g in self.games_list} + + if new_game_ids != current_game_ids: + self.logger.info( + f"Found {len(team_games)} upcoming fights within window for display." + ) + self.games_list = team_games + if ( + not self.current_game + or not self.games_list + or self.current_game["id"] not in new_game_ids + ): + self.current_game_index = 0 + self.current_game = self.games_list[0] if self.games_list else None + self.last_game_switch = current_time + else: + try: + self.current_game_index = next( + i + for i, g in enumerate(self.games_list) + if g["id"] == self.current_game["id"] + ) + self.current_game = self.games_list[self.current_game_index] + except StopIteration: + self.current_game_index = 0 + self.current_game = self.games_list[0] + self.last_game_switch = current_time + + elif self.games_list: + self.current_game = self.games_list[self.current_game_index] + + if not self.games_list: + self.logger.info("No relevant upcoming fights found to display.") + self.current_game = None + + if should_log and not self.games_list: + self.logger.debug( + f"Favorite fighters: {self.favorite_fighters}" + ) + self.logger.debug( + f"Total upcoming fights before filtering: {len(processed_games)}" + ) + self.last_log_time = current_time + elif should_log: + self.last_log_time = current_time + + except Exception as e: + self.logger.error( + f"Error updating upcoming fights: {e}", exc_info=True + ) + + +class MMALive(MMA, SportsLive): + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + + def _test_mode_update(self): + if self.current_game and self.current_game["is_live"]: + minutes = int(self.current_game["clock"].split(":")[0]) + seconds = int(self.current_game["clock"].split(":")[1]) + seconds -= 1 + if seconds < 0: + seconds = 59 + minutes -= 1 + if minutes < 0: + minutes = 19 + if self.current_game["period"] < 3: + self.current_game["period"] += 1 + else: + self.current_game["period"] = 1 + self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the detailed scorebug layout for a live MMA fight.""" + try: + main_img = Image.new( + "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255) + ) + overlay = Image.new( + "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0) + ) + draw_overlay = ImageDraw.Draw(overlay) + + fighter1_image = self._load_and_resize_headshot( + game["fighter1_id"], + game["fighter1_name"], + game["fighter1_image_path"], + game.get("fighter1_image_url"), + ) + fighter2_image = self._load_and_resize_headshot( + game["fighter2_id"], + game["fighter2_name"], + game["fighter2_image_path"], + game.get("fighter2_image_url"), + ) + + if not fighter1_image or not fighter2_image: + self.logger.error( + f"Failed to load headshots for live fight: {game.get('id')}" + ) + draw_final = ImageDraw.Draw(main_img.convert("RGB")) + self._draw_text_with_outline( + draw_final, "Image Error", (5, 5), self.fonts["status"] + ) + self.display_manager.image.paste(main_img.convert("RGB"), (0, 0)) + self.display_manager.update_display() + return + + center_y = self.display_height // 2 + + # Fighter 1 (right side) headshot with layout offsets + home_x = ( + self.display_width - fighter1_image.width + 10 + + self._get_layout_offset("home_logo", "x_offset") + ) + home_y = center_y - (fighter1_image.height // 2) + self._get_layout_offset("home_logo", "y_offset") + main_img.paste(fighter1_image, (home_x, home_y), fighter1_image) + + # Fighter 2 (left side) headshot with layout offsets + away_x = -10 + self._get_layout_offset("away_logo", "x_offset") + away_y = center_y - (fighter2_image.height // 2) + self._get_layout_offset("away_logo", "y_offset") + main_img.paste(fighter2_image, (away_x, away_y), fighter2_image) + + # Round and Clock (top center) + period_clock_text = ( + f"{game.get('period_text', '')} {game.get('clock', '')}".strip() + ) + if game.get("is_period_break"): + period_clock_text = game.get("status_text", "Round Break") + + status_width = draw_overlay.textlength( + period_clock_text, font=self.fonts["time"] + ) + status_x = (self.display_width - status_width) // 2 + self._get_layout_offset("status_text", "x_offset") + status_y = 1 + self._get_layout_offset("status_text", "y_offset") + self._draw_text_with_outline( + draw_overlay, + period_clock_text, + (status_x, status_y), + self.fonts["time"], + ) + + # Scores (centered) + home_score = str(game.get("home_score", "0")) + away_score = str(game.get("away_score", "0")) + score_text = f"{away_score}-{home_score}" + score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) + score_x = (self.display_width - score_width) // 2 + self._get_layout_offset("score", "x_offset") + score_y = (self.display_height // 2) - 3 + self._get_layout_offset("score", "y_offset") + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + ) + + # Draw odds if available + if "odds" in game and game["odds"]: + self._draw_dynamic_odds( + draw_overlay, game["odds"], self.display_width, self.display_height + ) + + # Draw records if enabled + if self.show_records: + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + self.logger.debug("Loaded 6px record font successfully") + except IOError: + record_font = ImageFont.load_default() + self.logger.warning( + f"Failed to load 6px font, using default font (size: {record_font.size})" + ) + + fighter2_name = game.get("fighter2_name", "") + fighter1_name = game.get("fighter1_name", "") + + record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height - 1 + self.logger.debug( + f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" + ) + + # Display fighter 2 record (left side) + if fighter2_name: + fighter2_record = game.get("fighter2_record", "") + if fighter2_record: + fighter2_text = fighter2_record + fighter2_record_x = 3 + self.logger.debug( + f"Drawing fighter2 record '{fighter2_text}' at ({fighter2_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + fighter2_text, + (fighter2_record_x, record_y), + record_font, + ) + + # Display fighter 1 record (right side) + if fighter1_name: + fighter1_record = game.get("fighter1_record", "") + if fighter1_record: + fighter1_text = fighter1_record + fighter1_record_bbox = draw_overlay.textbbox( + (0, 0), fighter1_text, font=record_font + ) + fighter1_record_width = fighter1_record_bbox[2] - fighter1_record_bbox[0] + fighter1_record_x = self.display_width - fighter1_record_width - 3 + self.logger.debug( + f"Drawing fighter1 record '{fighter1_text}' at ({fighter1_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + fighter1_text, + (fighter1_record_x, record_y), + record_font, + ) + + # Composite the text overlay onto the main image + main_img = Image.alpha_composite(main_img, overlay) + main_img = main_img.convert("RGB") + + # Display the final image + self.display_manager.image.paste(main_img, (0, 0)) + self.display_manager.update_display() + + except Exception as e: + self.logger.error( + f"Error displaying live MMA fight: {e}", exc_info=True + ) diff --git a/plugin-repos/ufc-scoreboard/requirements.txt b/plugin-repos/ufc-scoreboard/requirements.txt new file mode 100644 index 000000000..f64baef09 --- /dev/null +++ b/plugin-repos/ufc-scoreboard/requirements.txt @@ -0,0 +1,5 @@ +# UFC Scoreboard Plugin Requirements +# All dependencies are provided by LEDMatrix core: +# requests>=2.25.0 - API calls +# pytz>=2021.1 - Timezone handling +# Pillow>=8.0.0 - Image manipulation diff --git a/plugin-repos/ufc-scoreboard/scroll_display.py b/plugin-repos/ufc-scoreboard/scroll_display.py new file mode 100644 index 000000000..444f45355 --- /dev/null +++ b/plugin-repos/ufc-scoreboard/scroll_display.py @@ -0,0 +1,371 @@ +""" +Scroll Display Handler for UFC Scoreboard Plugin + +Implements high-FPS horizontal scrolling of all matching fights with +UFC separator icons. Uses ScrollHelper for efficient numpy-based scrolling. + +Based on scroll_display.py from football-scoreboard plugin. +UFC/MMA adaptation based on work by Alex Resnick (legoguy1000) - PR #137 +""" + +import logging +import time +import os +from pathlib import Path +from typing import Dict, Any, List, Optional +from PIL import Image + +try: + from src.common.scroll_helper import ScrollHelper +except ImportError: + ScrollHelper = None + +from fight_renderer import FightRenderer + +logger = logging.getLogger(__name__) + + +class ScrollDisplayManager: + """ + Handles scroll display mode for the UFC scoreboard plugin. + + This class: + - Collects all fights matching criteria + - Pre-renders each fight using FightRenderer + - Adds UFC separator icons between fight card groups + - Composes a single wide image using ScrollHelper + - Implements dynamic duration based on total content width + """ + + # Path to UFC separator icon + UFC_SEPARATOR_ICON = "assets/sports/ufc_logos/UFC.png" + + def __init__( + self, + display_manager, + config: Dict[str, Any], + custom_logger: Optional[logging.Logger] = None, + ): + self.display_manager = display_manager + self.config = config + self.logger = custom_logger or logger + + # Get display dimensions + if hasattr(display_manager, "matrix") and display_manager.matrix is not None: + self.display_width = display_manager.matrix.width + self.display_height = display_manager.matrix.height + else: + self.display_width = getattr(display_manager, "width", 128) + self.display_height = getattr(display_manager, "height", 32) + + # Initialize ScrollHelper + if ScrollHelper: + self.scroll_helper = ScrollHelper( + self.display_width, self.display_height, self.logger + ) + self._configure_scroll_helper() + else: + self.scroll_helper = None + self.logger.error("ScrollHelper not available - scroll mode will not work") + + # Shared headshot cache for fight renderer + self._headshot_cache: Dict[str, Image.Image] = {} + + # Separator icon cache + self._separator_icons: Dict[str, Image.Image] = {} + self._load_separator_icons() + + # Tracking state + self._current_fights: List[Dict] = [] + self._current_fight_type: str = "" + self._current_leagues: List[str] = [] + self._is_scrolling = False + self._scroll_start_time: Optional[float] = None + self._last_log_time: float = 0 + self._log_interval: float = 5.0 + + # Performance tracking + self._frame_count: int = 0 + self._fps_sample_start: float = time.time() + + def _configure_scroll_helper(self) -> None: + """Configure scroll helper with settings from config.""" + if not self.scroll_helper: + return + + scroll_settings = self._get_scroll_settings() + + # Set scroll speed + scroll_speed = scroll_settings.get("scroll_speed", 50.0) + scroll_delay = scroll_settings.get("scroll_delay", 0.01) + + self.scroll_helper.set_scroll_speed(scroll_speed) + self.scroll_helper.set_scroll_delay(scroll_delay) + + # Enable dynamic duration + dynamic_duration = scroll_settings.get("dynamic_duration", True) + self.scroll_helper.set_dynamic_duration_settings( + enabled=dynamic_duration, + min_duration=30, + max_duration=600, + buffer=0.2, + ) + + # Use frame-based scrolling + self.scroll_helper.set_frame_based_scrolling(True) + + # Convert scroll_speed to pixels per frame + if scroll_speed < 10.0: + pixels_per_frame = scroll_speed + else: + pixels_per_frame = scroll_speed * scroll_delay + + pixels_per_frame = max(0.1, min(5.0, pixels_per_frame)) + self.scroll_helper.set_scroll_speed(pixels_per_frame) + + effective_pps = pixels_per_frame / scroll_delay if scroll_delay > 0 else pixels_per_frame * 100 + + self.logger.info( + f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, delay={scroll_delay}s " + f"(effective {effective_pps:.1f} px/s), dynamic_duration={dynamic_duration}" + ) + + def _get_scroll_settings(self) -> Dict[str, Any]: + """Get scroll settings from config.""" + defaults = { + "scroll_speed": 50.0, + "scroll_delay": 0.01, + "gap_between_games": 48, + "show_league_separators": True, + "dynamic_duration": True, + } + + ufc_config = self.config.get("ufc", {}) + ufc_scroll = ufc_config.get("scroll_settings", {}) + if ufc_scroll: + return {**defaults, **ufc_scroll} + + return defaults + + def _load_separator_icons(self) -> None: + """Load and resize UFC separator icon.""" + separator_height = self.display_height - 4 + + if os.path.exists(self.UFC_SEPARATOR_ICON): + try: + ufc_icon = Image.open(self.UFC_SEPARATOR_ICON) + if ufc_icon.mode != "RGBA": + ufc_icon = ufc_icon.convert("RGBA") + aspect = ufc_icon.width / ufc_icon.height + new_width = int(separator_height * aspect) + ufc_icon = ufc_icon.resize( + (new_width, separator_height), Image.Resampling.LANCZOS + ) + self._separator_icons["ufc"] = ufc_icon + self.logger.debug(f"Loaded UFC separator icon: {new_width}x{separator_height}") + except Exception as e: + self.logger.error(f"Error loading UFC separator icon: {e}") + else: + self.logger.warning(f"UFC separator icon not found at {self.UFC_SEPARATOR_ICON}") + + def _determine_fight_type(self, fight: Dict) -> str: + """Determine fight type from its data.""" + if fight.get("is_live"): + return "live" + elif fight.get("is_final"): + return "recent" + elif fight.get("is_upcoming"): + return "upcoming" + + # Fallback: check status dict + status = fight.get("status") + if isinstance(status, dict): + state = status.get("state", "") + if state == "in": + return "live" + elif state == "post": + return "recent" + return "upcoming" + + def has_cached_content(self) -> bool: + """Check if scroll content is cached and ready.""" + return ( + self.scroll_helper is not None + and hasattr(self.scroll_helper, "cached_image") + and self.scroll_helper.cached_image is not None + ) + + def prepare_and_display( + self, + fights: List[Dict], + fight_type: str, + leagues: List[str], + rankings_cache: Dict[str, int] = None, + ) -> bool: + """ + Prepare scrolling content from a list of fights. + + Args: + fights: List of fight dictionaries + fight_type: 'live', 'recent', 'upcoming', or 'mixed' + leagues: List of leagues (usually just ['ufc']) + rankings_cache: Not used for MMA, kept for API compatibility + + Returns: + True if content was prepared successfully + """ + if not self.scroll_helper: + self.logger.error("ScrollHelper not available") + return False + + if not fights: + self.logger.debug("No fights to prepare for scroll") + return False + + scroll_settings = self._get_scroll_settings() + gap_between_fights = scroll_settings.get("gap_between_games", 48) + show_separators = scroll_settings.get("show_league_separators", True) + + # Get display options from UFC config + ufc_config = self.config.get("ufc", {}) + display_options = ufc_config.get("display_options", {}) + + # Create fight renderer + renderer = FightRenderer( + self.display_width, + self.display_height, + self.config, + headshot_cache=self._headshot_cache, + custom_logger=self.logger, + ) + + # Pre-render all fight cards + content_items: List[Image.Image] = [] + current_league = None + + for fight in fights: + fight_league = fight.get("league", "ufc") + + # Add league separator if switching leagues or at start + if show_separators: + if current_league is None or fight_league != current_league: + separator = self._separator_icons.get(fight_league) + if separator: + sep_img = Image.new( + "RGB", + (separator.width + 8, self.display_height), + (0, 0, 0), + ) + y_offset = (self.display_height - separator.height) // 2 + sep_img.paste(separator, (4, y_offset), separator) + content_items.append(sep_img) + + current_league = fight_league + + # Determine fight type from data + individual_type = self._determine_fight_type(fight) + + # Render fight card + fight_img = renderer.render_fight_card( + fight, fight_type=individual_type, display_options=display_options + ) + + if fight_img: + # Add horizontal padding + padding = 12 + padded_width = fight_img.width + (padding * 2) + padded_img = Image.new( + "RGB", (padded_width, fight_img.height), (0, 0, 0) + ) + padded_img.paste(fight_img, (padding, 0)) + content_items.append(padded_img) + else: + self.logger.warning( + f"Failed to render fight card for {fight.get('fighter2_name', '?')} vs {fight.get('fighter1_name', '?')}" + ) + + if not content_items: + self.logger.warning("No fight cards were rendered") + return False + + # Create scrolling image + try: + self.scroll_helper.create_scrolling_image( + content_items, + item_gap=gap_between_fights, + element_gap=0, + ) + + self._current_fights = fights + self._current_fight_type = fight_type + self._current_leagues = leagues + self._is_scrolling = True + self._scroll_start_time = time.time() + self._frame_count = 0 + + self.logger.info( + f"Prepared scroll content: {len(fights)} fights, " + f"{len(content_items)} items (with separators)" + ) + return True + + except Exception as e: + self.logger.error(f"Error creating scrolling image: {e}", exc_info=True) + return False + + def display_scroll_frame(self) -> bool: + """ + Display the next frame of scrolling content. + + Returns: + True if a frame was displayed, False if scroll is complete or no content + """ + if not self.scroll_helper or not self.scroll_helper.cached_image: + return False + + # Update scroll position + self.scroll_helper.update_scroll_position() + + # Get visible portion + visible = self.scroll_helper.get_visible_portion() + if not visible: + return False + + try: + self.display_manager.image = visible + self.display_manager.update_display() + + self._frame_count += 1 + self.scroll_helper.log_frame_rate() + self._log_scroll_progress() + + return True + except Exception as e: + self.logger.error(f"Error displaying scroll frame: {e}") + return False + + def _log_scroll_progress(self) -> None: + """Log scroll progress periodically.""" + current_time = time.time() + if current_time - self._last_log_time >= self._log_interval: + elapsed = current_time - (self._scroll_start_time or current_time) + fps = self._frame_count / elapsed if elapsed > 0 else 0 + self.logger.debug( + f"Scroll progress: {self._frame_count} frames, " + f"{fps:.1f} FPS, {elapsed:.1f}s elapsed" + ) + self._last_log_time = current_time + + def is_scroll_complete(self) -> bool: + """Check if the scroll has completed one full cycle.""" + if not self.scroll_helper: + return True + return self.scroll_helper.is_scroll_complete() + + def reset(self) -> None: + """Reset scroll state.""" + self._is_scrolling = False + self._current_fights = [] + self._frame_count = 0 + if self.scroll_helper: + self.scroll_helper.reset() diff --git a/plugin-repos/ufc-scoreboard/sports.py b/plugin-repos/ufc-scoreboard/sports.py new file mode 100644 index 000000000..a37a5eeef --- /dev/null +++ b/plugin-repos/ufc-scoreboard/sports.py @@ -0,0 +1,2494 @@ +# Based on original LEDMatrix sports architecture. UFC/MMA adaptation based on work by Alex Resnick (legoguy1000) - PR #137 +import logging +import os +import threading +import time +from abc import ABC, abstractmethod +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +import pytz +import requests +from PIL import Image, ImageDraw, ImageFont +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +# Import simplified dependencies for plugin use +try: + from dynamic_team_resolver import DynamicTeamResolver +except ImportError: + DynamicTeamResolver = None +from logo_downloader import LogoDownloader, download_missing_logo +from base_odds_manager import BaseOddsManager +from data_sources import ESPNDataSource + + +class SportsCore(ABC): + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + self.logger = logger + self.config = config + self.cache_manager = cache_manager + self.config_manager = getattr(cache_manager, "config_manager", None) + # Initialize odds manager + self.odds_manager = BaseOddsManager(self.cache_manager, self.config_manager) + self.display_manager = display_manager + # Get display dimensions from matrix (same as base SportsCore class) + # This ensures proper scaling for different display sizes + if hasattr(display_manager, 'matrix') and display_manager.matrix is not None: + self.display_width = display_manager.matrix.width + self.display_height = display_manager.matrix.height + else: + # Fallback to width/height properties (which also check matrix) + self.display_width = getattr(display_manager, "width", 128) + self.display_height = getattr(display_manager, "height", 32) + + self.sport_key = sport_key + self.sport = None + self.league = None + + # Initialize new architecture components (will be overridden by sport-specific classes) + self.sport_config = None + # Initialize data source + self.data_source = ESPNDataSource(logger) + self.mode_config = config.get( + f"{sport_key}_scoreboard", {} + ) # Changed config key + self.is_enabled: bool = self.mode_config.get("enabled", False) + self.show_odds: bool = self.mode_config.get("show_odds", False) + # Use LogoDownloader to get the correct default logo directory for this sport + from src.logo_downloader import LogoDownloader + default_logo_dir = Path(LogoDownloader().get_logo_directory(sport_key)) + self.logo_dir = default_logo_dir + self.update_interval: int = self.mode_config.get("update_interval_seconds", 60) + self.show_records: bool = self.mode_config.get("show_records", False) + self.show_ranking: bool = self.mode_config.get("show_ranking", False) + # Number of games to show (instead of time-based windows) + self.recent_games_to_show: int = self.mode_config.get( + "recent_games_to_show", 5 + ) # Show last 5 games + self.upcoming_games_to_show: int = self.mode_config.get( + "upcoming_games_to_show", 10 + ) # Show next 10 games + filtering_config = self.mode_config.get("filtering", {}) + self.show_favorite_teams_only: bool = self.mode_config.get( + "show_favorite_teams_only", + filtering_config.get("show_favorite_teams_only", False), + ) + self.show_all_live: bool = self.mode_config.get( + "show_all_live", + filtering_config.get("show_all_live", False), + ) + + self.session = requests.Session() + retry_strategy = Retry( + total=5, # increased number of retries + backoff_factor=1, # increased backoff factor + # added 429 to retry list + 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) + + self._logo_cache = {} + + # Set up headers + self.headers = { + "User-Agent": "LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)", + "Accept": "application/json", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + } + self.last_update = 0 + self.current_game = None + # Thread safety lock for shared game state + self._games_lock = threading.RLock() + self.fonts = self._load_fonts() + + # Initialize dynamic team resolver and resolve favorite teams + # UFC doesn't use team resolution, so make it optional + raw_favorite_teams = self.mode_config.get("favorite_teams", []) + if DynamicTeamResolver is not None: + self.dynamic_resolver = DynamicTeamResolver(cache_manager=cache_manager) + self.favorite_teams = self.dynamic_resolver.resolve_teams( + raw_favorite_teams, sport_key + ) + else: + self.dynamic_resolver = None + self.favorite_teams = raw_favorite_teams + + # Log dynamic team resolution + if raw_favorite_teams != self.favorite_teams: + self.logger.info( + f"Resolved dynamic teams: {raw_favorite_teams} -> {self.favorite_teams}" + ) + else: + self.logger.info(f"Favorite teams: {self.favorite_teams}") + + self.logger.setLevel(logging.INFO) + + # Initialize team rankings cache + self._team_rankings_cache = {} + self._rankings_cache_timestamp = 0 + self._rankings_cache_duration = 3600 # Cache rankings for 1 hour + + # Initialize background data service with optimized settings + # Hardcoded for memory optimization: 1 worker, 30s timeout, 3 retries + try: + from src.background_data_service import get_background_service + + self.background_service = get_background_service( + self.cache_manager, max_workers=1 + ) + self.background_fetch_requests = {} # Track background fetch requests + self.background_enabled = True + self.logger.info( + "Background service enabled with 1 worker (memory optimized)" + ) + except ImportError: + # Fallback if background service is not available + self.background_service = None + self.background_fetch_requests = {} + self.background_enabled = False + self.logger.warning( + "Background service not available - using synchronous fetching" + ) + + def _get_season_schedule_dates(self) -> tuple[str, str]: + return "", "" + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Placeholder draw method - subclasses should override.""" + # This base method will be simple, subclasses provide specifics + try: + img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) + draw = ImageDraw.Draw(img) + status = game.get("status_text", "N/A") + self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) + self.display_manager.image.paste(img, (0, 0)) + # Don't call update_display here, let subclasses handle it after drawing + except Exception as e: + self.logger.error( + f"Error in base _draw_scorebug_layout: {e}", exc_info=True + ) + + def display(self, force_clear: bool = False) -> bool: + """Render the current game. Returns False when nothing can be shown.""" + if not self.is_enabled: # Check if module is enabled + return False + + if not self.current_game: + # Clear the display so old content doesn't persist + if force_clear: + self.display_manager.clear() + self.display_manager.update_display() + current_time = time.time() + if not hasattr(self, "_last_warning_time"): + self._last_warning_time = 0 + if current_time - getattr(self, "_last_warning_time", 0) > 300: + self.logger.warning( + f"No game data available to display in {self.__class__.__name__}" + ) + setattr(self, "_last_warning_time", current_time) + return False + + try: + self._draw_scorebug_layout(self.current_game, force_clear) + # display_manager.update_display() should be called within subclass draw methods + # or after calling display() in the main loop. Let's keep it out of the base display. + return True + except Exception as e: + self.logger.error( + f"Error during display call in {self.__class__.__name__}: {e}", + exc_info=True, + ) + return False + + def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], default_size: int = 8) -> ImageFont.FreeTypeFont: + """ + Load a custom font from an element configuration dictionary. + + Args: + element_config: Configuration dict for a single element containing 'font' and 'font_size' keys + default_size: Default font size if not specified in config + + Returns: + PIL ImageFont object + """ + # Get font name and size, with defaults + font_name = element_config.get('font', 'PressStart2P-Regular.ttf') + font_size = int(element_config.get('font_size', default_size)) # Ensure integer for PIL + + # Build font path + font_path = os.path.join('assets', 'fonts', font_name) + + # Try to load the font + try: + if os.path.exists(font_path): + # Try loading as TTF first (works for both TTF and some BDF files with PIL) + if font_path.lower().endswith('.ttf'): + font = ImageFont.truetype(font_path, font_size) + self.logger.debug(f"Loaded font: {font_name} at size {font_size}") + return font + elif font_path.lower().endswith('.bdf'): + # PIL's ImageFont.truetype() can sometimes handle BDF files + # If it fails, we'll fall through to the default font + try: + font = ImageFont.truetype(font_path, font_size) + self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}") + return font + except Exception: + self.logger.warning(f"Could not load BDF font {font_name} with PIL, using default") + # Fall through to default + else: + self.logger.warning(f"Unknown font file type: {font_name}, using default") + else: + self.logger.warning(f"Font file not found: {font_path}, using default") + except Exception as e: + self.logger.error(f"Error loading font {font_name}: {e}, using default") + + # Fall back to default font + default_font_path = os.path.join('assets', 'fonts', 'PressStart2P-Regular.ttf') + try: + if os.path.exists(default_font_path): + return ImageFont.truetype(default_font_path, font_size) + else: + self.logger.warning("Default font not found, using PIL default") + return ImageFont.load_default() + except Exception as e: + self.logger.error(f"Error loading default font: {e}") + return ImageFont.load_default() + + def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """ + Get layout offset for a specific element and axis. + + Args: + element: Element name (e.g., 'home_logo', 'score', 'status_text') + axis: 'x_offset' or 'y_offset' (or 'away_x_offset', 'home_x_offset' for records) + default: Default value if not configured (default: 0) + + Returns: + Offset value from config or default (always returns int) + """ + try: + layout_config = self.config.get('customization', {}).get('layout', {}) + element_config = layout_config.get(element, {}) + offset_value = element_config.get(axis, default) + + # Ensure we return an integer (handle float/string from config) + if isinstance(offset_value, (int, float)): + return int(offset_value) + elif isinstance(offset_value, str): + # Try to convert string to int + try: + return int(float(offset_value)) + except (ValueError, TypeError): + self.logger.warning( + f"Invalid layout offset value for {element}.{axis}: '{offset_value}', using default {default}" + ) + return default + else: + return default + except Exception as e: + # Gracefully handle any config access errors + self.logger.debug(f"Error reading layout offset for {element}.{axis}: {e}, using default {default}") + return default + + def _load_fonts(self): + """Load fonts used by the scoreboard from config or use defaults.""" + fonts = {} + + # Get customization config, with backward compatibility + customization = self.config.get('customization', {}) + + # Load fonts from config with defaults for backward compatibility + score_config = customization.get('score_text', {}) + period_config = customization.get('period_text', {}) + team_config = customization.get('team_name', {}) + status_config = customization.get('status_text', {}) + detail_config = customization.get('detail_text', {}) + rank_config = customization.get('rank_text', {}) + + try: + fonts["score"] = self._load_custom_font_from_element_config(score_config, default_size=10) + fonts["time"] = self._load_custom_font_from_element_config(period_config, default_size=8) + fonts["team"] = self._load_custom_font_from_element_config(team_config, default_size=8) + fonts["status"] = self._load_custom_font_from_element_config(status_config, default_size=6) + fonts["detail"] = self._load_custom_font_from_element_config(detail_config, default_size=6) + fonts["rank"] = self._load_custom_font_from_element_config(rank_config, default_size=10) + self.logger.info("Successfully loaded fonts from config") + except Exception as e: + self.logger.error(f"Error loading fonts: {e}, using defaults") + # Fallback to hardcoded defaults + try: + fonts["score"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + fonts["time"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) + fonts["team"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) + fonts["status"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + fonts["detail"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + fonts["rank"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + except IOError: + self.logger.warning("Fonts not found, using default PIL font.") + fonts["score"] = ImageFont.load_default() + fonts["time"] = ImageFont.load_default() + fonts["team"] = ImageFont.load_default() + fonts["status"] = ImageFont.load_default() + fonts["detail"] = ImageFont.load_default() + fonts["rank"] = ImageFont.load_default() + return fonts + + def _draw_dynamic_odds( + self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int + ) -> None: + """Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team.""" + try: + # Skip odds rendering in test mode or if odds data is invalid + if ( + not odds + or isinstance(odds, dict) + and any( + isinstance(v, type) and hasattr(v, "__call__") + for v in odds.values() + ) + ): + self.logger.debug("Skipping odds rendering - test mode or invalid data") + return + + self.logger.debug(f"Drawing odds with data: {odds}") + + home_team_odds = odds.get("home_team_odds", {}) + away_team_odds = odds.get("away_team_odds", {}) + home_spread = home_team_odds.get("spread_odds") + away_spread = away_team_odds.get("spread_odds") + + # Get top-level spread as fallback + top_level_spread = odds.get("spread") + + # If we have a top-level spread and the individual spreads are None or 0, use the top-level + if top_level_spread is not None: + if home_spread is None or home_spread == 0.0: + home_spread = top_level_spread + if away_spread is None: + away_spread = -top_level_spread + + # Determine which team is favored (has negative spread) + # Add type checking to handle Mock objects in test environment + home_favored = False + away_favored = False + + if home_spread is not None and isinstance(home_spread, (int, float)): + home_favored = home_spread < 0 + if away_spread is not None and isinstance(away_spread, (int, float)): + away_favored = away_spread < 0 + + # Only show the negative spread (favored team) + favored_spread = None + favored_side = None + + if home_favored: + favored_spread = home_spread + favored_side = "home" + self.logger.debug(f"Home team favored with spread: {favored_spread}") + elif away_favored: + favored_spread = away_spread + favored_side = "away" + self.logger.debug(f"Away team favored with spread: {favored_spread}") + else: + self.logger.debug( + "No clear favorite - spreads: home={home_spread}, away={away_spread}" + ) + + # Show the negative spread on the appropriate side + if favored_spread is not None: + spread_text = str(favored_spread) + font = self.fonts["detail"] # Use detail font for odds + + if favored_side == "home": + # Home team is favored, show spread on right side + spread_width = draw.textlength(spread_text, font=font) + spread_x = width - spread_width # Top right + spread_y = 0 + self._draw_text_with_outline( + draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + ) + self.logger.debug( + f"Showing home spread '{spread_text}' on right side" + ) + else: + # Away team is favored, show spread on left side + spread_x = 0 # Top left + spread_y = 0 + self._draw_text_with_outline( + draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + ) + self.logger.debug( + f"Showing away spread '{spread_text}' on left side" + ) + + # Show over/under on the opposite side of the favored team + over_under = odds.get("over_under") + if over_under is not None and isinstance(over_under, (int, float)): + ou_text = f"O/U: {over_under}" + font = self.fonts["detail"] # Use detail font for odds + ou_width = draw.textlength(ou_text, font=font) + + if favored_side == "home": + # Home favored, show O/U on left side (opposite of spread) + ou_x = 0 # Top left + ou_y = 0 + self.logger.debug( + f"Showing O/U '{ou_text}' on left side (home favored)" + ) + elif favored_side == "away": + # Away favored, show O/U on right side (opposite of spread) + ou_x = width - ou_width # Top right + ou_y = 0 + self.logger.debug( + f"Showing O/U '{ou_text}' on right side (away favored)" + ) + else: + # No clear favorite, show O/U in center + ou_x = (width - ou_width) // 2 + ou_y = 0 + self.logger.debug( + f"Showing O/U '{ou_text}' in center (no clear favorite)" + ) + + self._draw_text_with_outline( + draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0) + ) + + except Exception as e: + self.logger.error(f"Error drawing odds: {e}", exc_info=True) + + def _draw_text_with_outline( + self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0) + ): + """Draw text with a black outline for better readability.""" + x, y = position + for dx, dy in [ + (-1, -1), + (-1, 0), + (-1, 1), + (0, -1), + (0, 1), + (1, -1), + (1, 0), + (1, 1), + ]: + draw.text((x + dx, y + dy), text, font=font, fill=outline_color) + draw.text((x, y), text, font=font, fill=fill) + + def _load_and_resize_logo( + self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None + ) -> Optional[Image.Image]: + """Load and resize a team logo, with caching and automatic download if missing.""" + self.logger.debug(f"Logo path: {logo_path}") + if team_abbrev in self._logo_cache: + self.logger.debug(f"Using cached logo for {team_abbrev}") + return self._logo_cache[team_abbrev] + + try: + # Try different filename variations first (for cases like TA&M vs TAANDM) + actual_logo_path = None + filename_variations = LogoDownloader.get_logo_filename_variations( + team_abbrev + ) + + for filename in filename_variations: + test_path = logo_path.parent / filename + if test_path.exists(): + actual_logo_path = test_path + self.logger.debug( + f"Found logo at alternative path: {actual_logo_path}" + ) + break + + # If no variation found, try to download missing logo + if not actual_logo_path and not logo_path.exists(): + self.logger.info( + f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download." + ) + + # Try to download the logo from ESPN API (this will create placeholder if download fails) + download_missing_logo( + self.sport_key, team_id, team_abbrev, logo_path, logo_url + ) + actual_logo_path = logo_path + + # Use the original path if no alternative was found + if not actual_logo_path: + actual_logo_path = logo_path + + # Only try to open the logo if the file exists + if os.path.exists(actual_logo_path): + logo = Image.open(actual_logo_path) + else: + self.logger.error( + f"Logo file still doesn't exist at {actual_logo_path} after download attempt" + ) + return None + if logo.mode != "RGBA": + logo = logo.convert("RGBA") + + max_width = int(self.display_width * 1.5) + max_height = int(self.display_height * 1.5) + logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + self._logo_cache[team_abbrev] = logo + return logo + + except Exception as e: + self.logger.error( + f"Error loading logo for {team_abbrev}: {e}", exc_info=True + ) + return None + + def _fetch_odds(self, game: Dict) -> None: + """Fetch odds for a specific game using async threading to prevent blocking.""" + try: + if not self.show_odds: + return + + # Determine update interval based on game state + is_live = game.get("is_live", False) + update_interval = ( + self.mode_config.get("live_odds_update_interval", 60) + if is_live + else self.mode_config.get("odds_update_interval", 3600) + ) + + # For upcoming games, use async fetch with short timeout to avoid blocking + # For live games, we want odds more urgently, but still use async to prevent blocking + import threading + import queue + + result_queue = queue.Queue() + + def fetch_odds(): + try: + odds_result = self.odds_manager.get_odds( + sport=self.sport, + league=self.league, + event_id=game["id"], + update_interval_seconds=update_interval, + ) + result_queue.put(('success', odds_result)) + except Exception as e: + result_queue.put(('error', e)) + + # Start odds fetch in a separate thread + odds_thread = threading.Thread(target=fetch_odds) + odds_thread.daemon = True + odds_thread.start() + + # Wait for result with timeout (shorter for upcoming games) + timeout = 2.0 if is_live else 1.5 # Live games get slightly longer timeout + try: + result_type, result_data = result_queue.get(timeout=timeout) + if result_type == 'success': + odds_data = result_data + if odds_data: + game["odds"] = odds_data + self.logger.debug( + f"Successfully fetched and attached odds for game {game['id']}" + ) + else: + self.logger.debug(f"No odds data returned for game {game['id']}") + else: + self.logger.debug(f"Odds fetch failed for game {game['id']}: {result_data}") + except queue.Empty: + # Timeout - odds will be fetched on next update if needed + # This prevents blocking the entire update() method + self.logger.debug(f"Odds fetch timed out for game {game['id']} (non-blocking)") + + except Exception as e: + self.logger.error( + f"Error fetching odds for game {game.get('id', 'N/A')}: {e}" + ) + + def _get_timezone(self): + """Get timezone from config, with fallback to cache_manager's config_manager.""" + try: + # First try plugin config + timezone_str = self.config.get("timezone") + # If not in plugin config, try to get from cache_manager's config_manager + if not timezone_str and hasattr(self, 'cache_manager') and hasattr(self.cache_manager, 'config_manager'): + timezone_str = self.cache_manager.config_manager.get_timezone() + # Final fallback to UTC + if not timezone_str: + timezone_str = "UTC" + + self.logger.debug(f"Using timezone: {timezone_str}") + return pytz.timezone(timezone_str) + except pytz.UnknownTimeZoneError: + self.logger.warning(f"Unknown timezone: {timezone_str}, falling back to UTC") + return pytz.utc + + def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: + """Check if we should log a warning based on cooldown period.""" + current_time = time.time() + if current_time - self._last_warning_time > cooldown: + self._last_warning_time = current_time + return True + return False + + def _fetch_team_rankings(self) -> Dict[str, int]: + """Fetch team rankings using the new architecture components.""" + current_time = time.time() + + # Check if we have cached rankings that are still valid + if ( + self._team_rankings_cache + and current_time - self._rankings_cache_timestamp + < self._rankings_cache_duration + ): + return self._team_rankings_cache + + try: + data = self.data_source.fetch_standings(self.sport, self.league) + + rankings = {} + rankings_data = data.get("rankings", []) + + if rankings_data: + # Use the first ranking (usually AP Top 25) + first_ranking = rankings_data[0] + teams = first_ranking.get("ranks", []) + + for team_data in teams: + team_info = team_data.get("team", {}) + team_abbr = team_info.get("abbreviation", "") + current_rank = team_data.get("current", 0) + + if team_abbr and current_rank > 0: + rankings[team_abbr] = current_rank + + # Cache the results + self._team_rankings_cache = rankings + self._rankings_cache_timestamp = current_time + + self.logger.debug(f"Fetched rankings for {len(rankings)} teams") + return rankings + + except Exception as e: + self.logger.error(f"Error fetching team rankings: {e}") + return {} + + @staticmethod + def _extract_team_record(team_data: Dict) -> str: + """Extract the overall record string from a competitor/team object. + + The ESPN scoreboard API uses ``records`` (plural) with a ``summary`` + field, while the team-schedule API uses ``record`` (singular) with a + ``displayValue`` field. This helper handles both formats so that + records display correctly regardless of which API provided the data. + """ + # Scoreboard API format: records[0].summary (e.g. "10-2") + records = team_data.get("records") + if records and isinstance(records, list) and len(records) > 0: + return records[0].get("summary", "") + + # Team-schedule API format: record[0].displayValue (e.g. "7-0") + record = team_data.get("record") + if record and isinstance(record, list) and len(record) > 0: + return record[0].get("displayValue", record[0].get("summary", "")) + + return "" + + def _extract_game_details_common( + self, game_event: Dict + ) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]: + if not game_event: + return None, None, None, None, None + try: + # Safe access to competitions array + competitions = game_event.get("competitions", []) + if not competitions: + self.logger.warning(f"No competitions data for game {game_event.get('id', 'unknown')}") + return None, None, None, None, None + competition = competitions[0] + status = competition.get("status") + if not status: + self.logger.warning(f"No status data for game {game_event.get('id', 'unknown')}") + return None, None, None, None, None + competitors = competition.get("competitors", []) + game_date_str = game_event["date"] + situation = competition.get("situation") + start_time_utc = None + try: + # Parse the datetime string + if game_date_str.endswith('Z'): + game_date_str = game_date_str.replace('Z', '+00:00') + dt = datetime.fromisoformat(game_date_str) + # Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC) + if dt.tzinfo is None: + # If naive, assume it's UTC + start_time_utc = dt.replace(tzinfo=pytz.UTC) + else: + # Convert to pytz.UTC for consistency + start_time_utc = dt.astimezone(pytz.UTC) + except ValueError: + self.logger.warning(f"Could not parse game date: {game_date_str}") + + home_team = next( + (c for c in competitors if c.get("homeAway") == "home"), None + ) + away_team = next( + (c for c in competitors if c.get("homeAway") == "away"), None + ) + + if not home_team or not away_team: + self.logger.warning( + f"Could not find home or away team in event: {game_event.get('id')}" + ) + return None, None, None, None, None + + try: + home_abbr = home_team["team"]["abbreviation"] + except KeyError: + home_abbr = home_team["team"]["name"][:3] + try: + away_abbr = away_team["team"]["abbreviation"] + except KeyError: + away_abbr = away_team["team"]["name"][:3] + + # Check if this is a favorite team game BEFORE doing expensive logging + is_favorite_game = self.favorite_teams and ( + home_abbr in self.favorite_teams or away_abbr in self.favorite_teams + ) + + # Only log debug info for favorite team games + if is_favorite_game: + self.logger.debug( + f"Processing favorite team game: {game_event.get('id')}" + ) + self.logger.debug( + f"Found teams: {away_abbr}@{home_abbr}, Status: {status['type']['name']}, State: {status['type']['state']}" + ) + + game_time, game_date = "", "" + if start_time_utc: + local_time = start_time_utc.astimezone(self._get_timezone()) + game_time = local_time.strftime("%I:%M%p").lstrip("0") + + # Check date format from config + use_short_date_format = self.config.get("display", {}).get( + "use_short_date_format", False + ) + if use_short_date_format: + game_date = local_time.strftime("%-m/%-d") + else: + # Note: display_manager.format_date_with_ordinal will be handled by plugin wrapper + game_date = local_time.strftime("%m/%d") # Simplified for plugin + + home_record = self._extract_team_record(home_team) + away_record = self._extract_team_record(away_team) + + # Don't show "0-0" records - set to blank instead + if home_record in {"0-0", "0-0-0"}: + home_record = "" + if away_record in {"0-0", "0-0-0"}: + away_record = "" + + details = { + "id": game_event.get("id"), + "game_time": game_time, + "game_date": game_date, + "start_time_utc": start_time_utc, + "status_text": status["type"][ + "shortDetail" + ], # e.g., "Final", "7:30 PM", "Q1 12:34" + "is_live": status["type"]["state"] == "in", + "is_final": status["type"]["state"] == "post", + "is_upcoming": ( + status["type"]["state"] == "pre" + or status["type"]["name"].lower() + in ["scheduled", "pre-game", "status_scheduled"] + ), + "is_halftime": status["type"]["state"] == "halftime" + or status["type"]["name"] == "STATUS_HALFTIME", # Added halftime check + "is_period_break": status["type"]["name"] + == "STATUS_END_PERIOD", # Added Period Break check + "home_abbr": home_abbr, + "home_id": home_team["id"], + "home_score": home_team.get("score", "0"), + "home_logo_path": self.logo_dir + / Path(f"{LogoDownloader.normalize_abbreviation(home_abbr)}.png"), + "home_logo_url": home_team["team"].get("logo"), + "home_record": home_record, + "away_record": away_record, + "away_abbr": away_abbr, + "away_id": away_team["id"], + "away_score": away_team.get("score", "0"), + "away_logo_path": self.logo_dir + / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), + "away_logo_url": away_team["team"].get("logo"), + "is_within_window": True, # Whether game is within display window + } + return details, home_team, away_team, status, situation + except Exception as e: + # Log the problematic event structure if possible + self.logger.error( + f"Error extracting game details: {e} from event: {game_event.get('id')}", + exc_info=True, + ) + return None, None, None, None, None + + @abstractmethod + def _extract_game_details(self, game_event: dict) -> dict | None: + details, _, _, _, _ = self._extract_game_details_common(game_event) + return details + + @abstractmethod + def _fetch_data(self) -> Optional[Dict]: + pass + + def _fetch_todays_games(self) -> Optional[Dict]: + """Fetch only today's games for live updates (not entire season).""" + try: + now = datetime.now() + formatted_date = now.strftime("%Y%m%d") + # Fetch todays games only + url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" + self.logger.debug(f"Fetching today's games for {self.sport}/{self.league} on date {formatted_date}") + response = self.session.get( + url, + params={"dates": formatted_date, "limit": 1000}, + headers=self.headers, + timeout=10, + ) + response.raise_for_status() + data = response.json() + events = data.get("events", []) + + self.logger.info( + f"Fetched {len(events)} todays games for {self.sport} - {self.league}" + ) + + # Log status of each game for debugging + if events: + for event in events: + status = event.get("competitions", [{}])[0].get("status", {}) + status_type = status.get("type", {}) + state = status_type.get("state", "unknown") + name = status_type.get("name", "unknown") + self.logger.debug( + f"Event {event.get('id', 'unknown')}: state={state}, name={name}, " + f"shortDetail={status_type.get('shortDetail', 'N/A')}" + ) + + return {"events": events} + except requests.exceptions.RequestException as e: + self.logger.error( + f"API error fetching todays games for {self.sport} - {self.league}: {e}" + ) + return None + + def _get_weeks_data(self) -> Optional[Dict]: + """ + Get partial data for immediate display while background fetch is in progress. + This fetches current/recent games only for quick response. + """ + try: + # Fetch current week and next few days for immediate display + now = datetime.now(pytz.utc) + immediate_events = [] + + start_date = now + timedelta(weeks=-2) + end_date = now + timedelta(weeks=1) + date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" + url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" + response = self.session.get( + url, + params={"dates": date_str, "limit": 1000}, + headers=self.headers, + timeout=10, + ) + response.raise_for_status() + data = response.json() + immediate_events = data.get("events", []) + + if immediate_events: + self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") + return {"events": immediate_events} + + except requests.exceptions.RequestException as e: + self.logger.warning( + f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}" + ) + return None + + def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): + pass + + def cleanup(self): + """Clean up resources when plugin is unloaded.""" + # Close HTTP session + if hasattr(self, 'session') and self.session: + try: + self.session.close() + except Exception as e: + self.logger.warning(f"Error closing session: {e}") + + # Clear caches + if hasattr(self, '_logo_cache'): + self._logo_cache.clear() + + self.logger.info(f"{self.__class__.__name__} cleanup completed") + + +class SportsUpcoming(SportsCore): + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + self.upcoming_games = [] # Store all fetched upcoming games initially + self.games_list = [] # Filtered list for display (favorite teams) + self.current_game_index = 0 + self.last_update = 0 + self.update_interval = self.mode_config.get( + "upcoming_update_interval", 3600 + ) # Check for recent games every hour + self.last_log_time = 0 + self.log_interval = 300 + self.last_warning_time = 0 + self.warning_cooldown = 300 + self.last_game_switch = 0 + self.game_display_duration = self.mode_config.get("upcoming_game_duration", 15) + + def _select_games_for_display( + self, processed_games: List[Dict], favorite_teams: List[str] + ) -> List[Dict]: + """ + Single-pass game selection with proper deduplication and counting. + + When a game involves two favorite teams, it counts toward BOTH teams' limits. + This prevents unexpected game counts from the multi-pass algorithm. + """ + # Sort by start time for consistent priority + sorted_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.max.replace(tzinfo=timezone.utc), + ) + + if not favorite_teams: + # No favorites: return all games (caller will apply limits) + return sorted_games + + selected_games = [] + selected_ids = set() + team_counts = {team: 0 for team in favorite_teams} + + for game in sorted_games: + game_id = game.get("id") + if game_id in selected_ids: + continue + + home = game.get("home_abbr") + away = game.get("away_abbr") + + home_fav = home in favorite_teams + away_fav = away in favorite_teams + + if not home_fav and not away_fav: + continue + + # Check if at least one favorite team still needs games + home_needs = home_fav and team_counts[home] < self.upcoming_games_to_show + away_needs = away_fav and team_counts[away] < self.upcoming_games_to_show + + if home_needs or away_needs: + selected_games.append(game) + selected_ids.add(game_id) + # Count game for ALL favorite teams involved + # This is key: one game counts toward limits of BOTH teams if both are favorites + if home_fav: + team_counts[home] += 1 + if away_fav: + team_counts[away] += 1 + + self.logger.debug( + f"Selected game {away}@{home}: team_counts={team_counts}" + ) + + # Check if all favorites are satisfied + if all(c >= self.upcoming_games_to_show for c in team_counts.values()): + self.logger.debug("All favorite teams satisfied, stopping selection") + break + + self.logger.info( + f"Selected {len(selected_games)} games for {len(favorite_teams)} " + f"favorite teams: {team_counts}" + ) + return selected_games + + def update(self): + """Update upcoming games data.""" + if not self.is_enabled: + return + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + self.last_update = current_time + + # Fetch rankings if enabled + if self.show_ranking: + self._fetch_team_rankings() + + try: + data = self._fetch_data() # Uses shared cache + if not data or "events" not in data: + self.logger.warning( + "No events found in shared data." + ) # Changed log prefix + if not self.games_list: + self.current_game = None + return + + events = data["events"] + # self.logger.info(f"Processing {len(events)} events from shared data.") # Changed log prefix + + processed_games = [] + favorite_games_found = 0 + all_upcoming_games = 0 # Count all upcoming games regardless of favorites + + for event in events: + game = self._extract_game_details(event) + # Count all upcoming games for debugging + if game and game["is_upcoming"]: + all_upcoming_games += 1 + + # Filter criteria: must be upcoming ('pre' state) + if game and game["is_upcoming"]: + # Only fetch odds for games that will be displayed + # If show_favorite_teams_only is True, filter by favorite teams + # But if no favorite teams are configured, show all games (fallback) + if self.show_favorite_teams_only and self.favorite_teams: + if ( + game["home_abbr"] not in self.favorite_teams + and game["away_abbr"] not in self.favorite_teams + ): + continue + processed_games.append(game) + # Count favorite team games for logging + if self.favorite_teams and ( + game["home_abbr"] in self.favorite_teams + or game["away_abbr"] in self.favorite_teams + ): + favorite_games_found += 1 + if self.show_odds: + self._fetch_odds(game) + + # Enhanced logging for debugging + self.logger.info(f"Found {all_upcoming_games} total upcoming games in data") + self.logger.info( + f"Found {len(processed_games)} upcoming games after filtering" + ) + + if processed_games: + for game in processed_games[:3]: # Show first 3 + self.logger.info( + f" {game['away_abbr']}@{game['home_abbr']} - {game['start_time_utc']}" + ) + + if self.favorite_teams and all_upcoming_games > 0: + self.logger.info(f"Favorite teams: {self.favorite_teams}") + self.logger.info( + f"Found {favorite_games_found} favorite team upcoming games" + ) + + # Use single-pass algorithm for game selection + # This properly handles games between two favorite teams (counts for both) + if self.show_favorite_teams_only and self.favorite_teams: + team_games = self._select_games_for_display( + processed_games, self.favorite_teams + ) + else: + # No favorite teams: show N total games sorted by time (schedule view) + team_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.max.replace(tzinfo=timezone.utc), + )[:self.upcoming_games_to_show] + self.logger.info( + f"No favorites configured: showing {len(team_games)} total upcoming games" + ) + + # Log changes or periodically + should_log = ( + current_time - self.last_log_time >= self.log_interval + or len(team_games) != len(self.games_list) + or any( + g1["id"] != g2.get("id") + for g1, g2 in zip(self.games_list, team_games) + ) + or (not self.games_list and team_games) + ) + + # Check if the list of games to display has changed (thread-safe) + with self._games_lock: + new_game_ids = {g["id"] for g in team_games} + current_game_ids = {g["id"] for g in self.games_list} + + if new_game_ids != current_game_ids: + self.logger.info( + f"Found {len(team_games)} upcoming games within window for display." + ) # Changed log prefix + self.games_list = team_games + if ( + not self.current_game + or not self.games_list + or self.current_game["id"] not in new_game_ids + ): + self.current_game_index = 0 + self.current_game = self.games_list[0] if self.games_list else None + self.last_game_switch = current_time + else: + try: + self.current_game_index = next( + i + for i, g in enumerate(self.games_list) + if g["id"] == self.current_game["id"] + ) + self.current_game = self.games_list[self.current_game_index] + except StopIteration: + self.current_game_index = 0 + self.current_game = self.games_list[0] + self.last_game_switch = current_time + + elif self.games_list: + self.current_game = self.games_list[ + self.current_game_index + ] # Update data + + if not self.games_list: + self.logger.info( + "No relevant upcoming games found to display." + ) # Changed log prefix + self.current_game = None + + if should_log and not self.games_list: + # Log favorite teams only if no games are found and logging is needed + self.logger.debug( + f"Favorite teams: {self.favorite_teams}" + ) # Changed log prefix + self.logger.debug( + f"Total upcoming games before filtering: {len(processed_games)}" + ) # Changed log prefix + self.last_log_time = current_time + elif should_log: + self.last_log_time = current_time + + except Exception as e: + self.logger.error( + f"Error updating upcoming games: {e}", exc_info=True + ) # Changed log prefix + # self.current_game = None # Decide if clear on error + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the layout for an upcoming game.""" + try: + # Clear the display first to ensure full coverage (like weather plugin does) + if force_clear: + self.display_manager.clear() + + # Use display_manager.matrix dimensions directly to ensure full display coverage + display_width = self.display_manager.matrix.width if hasattr(self.display_manager, 'matrix') and self.display_manager.matrix else self.display_width + display_height = self.display_manager.matrix.height if hasattr(self.display_manager, 'matrix') and self.display_manager.matrix else self.display_height + + main_img = Image.new( + "RGBA", (display_width, display_height), (0, 0, 0, 255) + ) + overlay = Image.new( + "RGBA", (display_width, display_height), (0, 0, 0, 0) + ) + draw_overlay = ImageDraw.Draw(overlay) + + home_logo = self._load_and_resize_logo( + game["home_id"], + game["home_abbr"], + game["home_logo_path"], + game.get("home_logo_url"), + ) + away_logo = self._load_and_resize_logo( + game["away_id"], + game["away_abbr"], + game["away_logo_path"], + game.get("away_logo_url"), + ) + + if not home_logo or not away_logo: + self.logger.error( + f"Failed to load logos for game: {game.get('id')}" + ) # Changed log prefix + draw_final = ImageDraw.Draw(main_img.convert("RGB")) + self._draw_text_with_outline( + draw_final, "Logo Error", (5, 5), self.fonts["status"] + ) + self.display_manager.image = main_img.convert("RGB") + self.display_manager.update_display() + return + + center_y = display_height // 2 + + # MLB-style logo positions with layout offsets + home_x = display_width - home_logo.width + 2 + self._get_layout_offset('home_logo', 'x_offset') + home_y = center_y - (home_logo.height // 2) + self._get_layout_offset('home_logo', 'y_offset') + main_img.paste(home_logo, (home_x, home_y), home_logo) + + away_x = -2 + self._get_layout_offset('away_logo', 'x_offset') + away_y = center_y - (away_logo.height // 2) + self._get_layout_offset('away_logo', 'y_offset') + main_img.paste(away_logo, (away_x, away_y), away_logo) + + # Draw Text Elements on Overlay + game_date = game.get("game_date", "") + game_time = game.get("game_time", "") + + # Note: Rankings are now handled in the records/rankings section below + + # "Next Game" at the top (use smaller status font) with layout offsets + status_font = self.fonts["status"] + if display_width > 128: + status_font = self.fonts["time"] + status_text = "Next Game" + status_width = draw_overlay.textlength(status_text, font=status_font) + status_x = (display_width - status_width) // 2 + self._get_layout_offset('status_text', 'x_offset') + status_y = 1 + self._get_layout_offset('status_text', 'y_offset') # Changed from 2 + self._draw_text_with_outline( + draw_overlay, status_text, (status_x, status_y), status_font + ) + + # Date text (centered, below "Next Game") with layout offsets + date_width = draw_overlay.textlength(game_date, font=self.fonts["time"]) + date_x = (display_width - date_width) // 2 + self._get_layout_offset('date', 'x_offset') + # Adjust Y position to stack date and time nicely + date_y = center_y - 7 + self._get_layout_offset('date', 'y_offset') # Raise date slightly + self._draw_text_with_outline( + draw_overlay, game_date, (date_x, date_y), self.fonts["time"] + ) + + # Time text (centered, below Date) with layout offsets + time_width = draw_overlay.textlength(game_time, font=self.fonts["time"]) + time_x = (display_width - time_width) // 2 + self._get_layout_offset('time', 'x_offset') + time_y = date_y + 9 + self._get_layout_offset('time', 'y_offset') # Place time below date + self._draw_text_with_outline( + draw_overlay, game_time, (time_x, time_y), self.fonts["time"] + ) + + # Draw odds if available + if "odds" in game and game["odds"]: + self._draw_dynamic_odds( + draw_overlay, game["odds"], display_width, display_height + ) + + # Draw records or rankings if enabled + if self.show_records or self.show_ranking: + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + self.logger.debug(f"Loaded 6px record font successfully") + except IOError: + record_font = ImageFont.load_default() + self.logger.warning( + f"Failed to load 6px font, using default font (size: {record_font.size})" + ) + + # Get team abbreviations + away_abbr = game.get("away_abbr", "") + home_abbr = game.get("home_abbr", "") + + record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height + self._get_layout_offset('records', 'y_offset') + self.logger.debug( + f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" + ) + + # Display away team info + if away_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + away_text = "" + elif self.show_ranking: + # Show ranking only if available + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + away_text = "" + elif self.show_records: + # Show record only when rankings are disabled + away_text = game.get("away_record", "") + else: + away_text = "" + + if away_text: + away_record_x = 0 + self._get_layout_offset('records', 'away_x_offset') + self.logger.debug( + f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + away_text, + (away_record_x, record_y), + record_font, + ) + + # Display home team info + if home_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + home_text = "" + elif self.show_ranking: + # Show ranking only if available + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + home_text = "" + elif self.show_records: + # Show record only when rankings are disabled + home_text = game.get("home_record", "") + else: + home_text = "" + + if home_text: + home_record_bbox = draw_overlay.textbbox( + (0, 0), home_text, font=record_font + ) + home_record_width = home_record_bbox[2] - home_record_bbox[0] + home_record_x = self.display_width - home_record_width + self._get_layout_offset('records', 'home_x_offset') + self.logger.debug( + f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + home_text, + (home_record_x, record_y), + record_font, + ) + + # Composite and display + main_img = Image.alpha_composite(main_img, overlay) + main_img = main_img.convert("RGB") + self.display_manager.image.paste(main_img, (0, 0)) + self.display_manager.update_display() # Update display here + + except Exception as e: + self.logger.error( + f"Error displaying upcoming game: {e}", exc_info=True + ) # Changed log prefix + + def display(self, force_clear=False) -> bool: + """Display upcoming games, handling switching.""" + if not self.is_enabled: + return False + + if not self.games_list: + # Clear the display so old content doesn't persist + if force_clear: + self.display_manager.clear() + self.display_manager.update_display() + if self.current_game: + self.current_game = None # Clear state if list empty + current_time = time.time() + # Log warning periodically if no games found + if current_time - self.last_warning_time > self.warning_cooldown: + self.logger.info( + "No upcoming games found for favorite teams to display." + ) # Changed log prefix + self.last_warning_time = current_time + return False # Skip display update + + try: + current_time = time.time() + + # Check if it's time to switch games (protected by lock for thread safety) + with self._games_lock: + if ( + len(self.games_list) > 1 + and current_time - self.last_game_switch >= self.game_display_duration + ): + self.current_game_index = (self.current_game_index + 1) % len( + self.games_list + ) + self.current_game = self.games_list[self.current_game_index] + self.last_game_switch = current_time + force_clear = True # Force redraw on switch + + # Log team switching with sport prefix + if self.current_game: + away_abbr = self.current_game.get("away_abbr", "UNK") + home_abbr = self.current_game.get("home_abbr", "UNK") + sport_prefix = ( + self.sport_key.upper() + if hasattr(self, "sport_key") + else "SPORT" + ) + self.logger.info( + f"[{sport_prefix} Upcoming] Showing {away_abbr} vs {home_abbr}" + ) + else: + self.logger.debug( + f"Switched to game index {self.current_game_index}" + ) + + if self.current_game: + self._draw_scorebug_layout(self.current_game, force_clear) + # update_display() is called within _draw_scorebug_layout for upcoming + + except Exception as e: + self.logger.error( + f"Error in display loop: {e}", exc_info=True + ) # Changed log prefix + return False + + return True + + +class SportsRecent(SportsCore): + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + self.recent_games = [] # Store all fetched recent games initially + self.games_list = [] # Filtered list for display (favorite teams) + self.current_game_index = 0 + self.last_update = 0 + self.update_interval = self.mode_config.get( + "recent_update_interval", 3600 + ) # Check for recent games every hour + self.last_game_switch = 0 + self.game_display_duration = self.mode_config.get("recent_game_duration", 15) + self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 + + def _get_zero_clock_duration(self, game_id: str) -> float: + """Track how long a game has been at 0:00 clock.""" + current_time = time.time() + if game_id not in self._zero_clock_timestamps: + self._zero_clock_timestamps[game_id] = current_time + return 0.0 + return current_time - self._zero_clock_timestamps[game_id] + + def _clear_zero_clock_tracking(self, game_id: str) -> None: + """Clear tracking when game clock moves away from 0:00 or game ends.""" + if game_id in self._zero_clock_timestamps: + del self._zero_clock_timestamps[game_id] + + def _select_recent_games_for_display( + self, processed_games: List[Dict], favorite_teams: List[str] + ) -> List[Dict]: + """ + Single-pass game selection for recent games with proper deduplication. + + When a game involves two favorite teams, it counts toward BOTH teams' limits. + Games are sorted by most recent first. + """ + # Sort by start time, most recent first + sorted_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + + if not favorite_teams: + # No favorites: return all games (caller will apply limits) + return sorted_games + + selected_games = [] + selected_ids = set() + team_counts = {team: 0 for team in favorite_teams} + + for game in sorted_games: + game_id = game.get("id") + if game_id in selected_ids: + continue + + home = game.get("home_abbr") + away = game.get("away_abbr") + + home_fav = home in favorite_teams + away_fav = away in favorite_teams + + if not home_fav and not away_fav: + continue + + # Check if at least one favorite team still needs games + home_needs = home_fav and team_counts[home] < self.recent_games_to_show + away_needs = away_fav and team_counts[away] < self.recent_games_to_show + + if home_needs or away_needs: + selected_games.append(game) + selected_ids.add(game_id) + # Count game for ALL favorite teams involved + if home_fav: + team_counts[home] += 1 + if away_fav: + team_counts[away] += 1 + + self.logger.debug( + f"Selected recent game {away}@{home}: team_counts={team_counts}" + ) + + # Check if all favorites are satisfied + if all(c >= self.recent_games_to_show for c in team_counts.values()): + self.logger.debug("All favorite teams satisfied, stopping selection") + break + + return selected_games + + def update(self): + """Update recent games data.""" + if not self.is_enabled: + return + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + self.last_update = current_time # Update time even if fetch fails + + # Fetch rankings if enabled + if self.show_ranking: + self._fetch_team_rankings() + + try: + data = self._fetch_data() # Uses shared cache + if not data or "events" not in data: + self.logger.warning( + "No events found in shared data." + ) # Changed log prefix + if not self.games_list: + self.current_game = None # Clear display if no games were showing + return + + events = data["events"] + self.logger.info( + f"Processing {len(events)} events from shared data." + ) # Changed log prefix + + # Define date range for "recent" games (last 21 days to capture games from 3 weeks ago) + now = datetime.now(timezone.utc) + recent_cutoff = now - timedelta(days=21) + self.logger.info( + f"Current time: {now}, Recent cutoff: {recent_cutoff} (21 days ago)" + ) + + # Process games and filter for final games, date range & favorite teams + processed_games = [] + for event in events: + game = self._extract_game_details(event) + if not game: + continue + + # Check if game appears finished even if not marked as "post" yet + # This handles cases where API hasn't updated status yet + appears_finished = False + game_id = game.get("id") + if not game.get("is_final", False): + # Check if game appears to be over based on clock/period + clock = game.get("clock", "") + period = game.get("period", 0) + period_text = game.get("status_text", "").lower() + + if period >= 4: + clock_normalized = clock.replace(":", "").strip() + + # Explicit "final" in status text is definitive + if "final" in period_text: + appears_finished = True + self._clear_zero_clock_tracking(game_id) + self.logger.debug( + f"Game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"appears finished (period_text contains 'final')" + ) + elif clock_normalized in ["000", "00", ""] or clock == "0:00" or clock == ":00": + # Clock at 0:00 but no explicit final - use grace period + # This prevents premature transitions during potential OT or reviews + zero_clock_duration = self._get_zero_clock_duration(game_id) + + # Only mark finished after 2 minute grace period (allows OT decisions) + if zero_clock_duration >= 120: + appears_finished = True + self.logger.debug( + f"Game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"appears finished after {zero_clock_duration:.0f}s at 0:00 " + f"(period={period}, clock={clock})" + ) + else: + self.logger.debug( + f"Game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"at 0:00 but only for {zero_clock_duration:.0f}s - waiting for confirmation" + ) + else: + # Clock is not at 0:00, clear any tracking + self._clear_zero_clock_tracking(game_id) + else: + # Game is marked final, clear tracking + self._clear_zero_clock_tracking(game_id) + + # Filter criteria: must be final OR appear finished, AND within recent date range + is_eligible = game.get("is_final", False) or appears_finished + if is_eligible: + game_time = game.get("start_time_utc") + if game_time and game_time >= recent_cutoff: + processed_games.append(game) + # Log when adding games, especially if they appear finished but aren't marked final + final_status = "final" if game.get("is_final") else "appears finished" + self.logger.info( + f"Added {final_status} game to recent list: " + f"{game.get('away_abbr')}@{game.get('home_abbr')} " + f"({game.get('away_score')}-{game.get('home_score')}) " + f"at {game_time.strftime('%Y-%m-%d %H:%M:%S UTC') if game_time else 'unknown time'}" + ) + elif game_time: + self.logger.debug( + f"Game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"is final but outside date range (game_time={game_time}, cutoff={recent_cutoff})" + ) + else: + # Log why game was filtered out (only for favorite teams to reduce noise) + if self.favorite_teams and (game.get("home_abbr") in self.favorite_teams or game.get("away_abbr") in self.favorite_teams): + self.logger.debug( + f"Game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"not included: is_final={game.get('is_final')}, " + f"period={game.get('period')}, clock={game.get('clock')}, " + f"status={game.get('status_text')}" + ) + # Use single-pass algorithm for game selection + # This properly handles games between two favorite teams (counts for both) + if self.show_favorite_teams_only and self.favorite_teams: + team_games = self._select_recent_games_for_display( + processed_games, self.favorite_teams + ) + # Debug: Show which games are selected for display + for i, game in enumerate(team_games): + self.logger.info( + f"Game {i+1} for display: {game['away_abbr']} @ {game['home_abbr']} - {game.get('start_time_utc')} - Score: {game['away_score']}-{game['home_score']}" + ) + else: + # No favorites or show_favorite_teams_only disabled: show N total games sorted by time + team_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + )[:self.recent_games_to_show] + self.logger.info( + f"No favorites configured: showing {len(team_games)} total recent games" + ) + + # Check if the list of games to display has changed (thread-safe) + with self._games_lock: + new_game_ids = {g["id"] for g in team_games} + current_game_ids = {g["id"] for g in self.games_list} + + if new_game_ids != current_game_ids: + self.logger.info( + f"Found {len(team_games)} final games within window for display." + ) # Changed log prefix + self.games_list = team_games + # Reset index if list changed or current game removed + if ( + not self.current_game + or not self.games_list + or self.current_game["id"] not in new_game_ids + ): + self.current_game_index = 0 + self.current_game = self.games_list[0] if self.games_list else None + self.last_game_switch = current_time # Reset switch timer + else: + # Try to maintain position if possible + try: + self.current_game_index = next( + i + for i, g in enumerate(self.games_list) + if g["id"] == self.current_game["id"] + ) + self.current_game = self.games_list[ + self.current_game_index + ] # Update data just in case + except StopIteration: + self.current_game_index = 0 + self.current_game = self.games_list[0] + self.last_game_switch = current_time + + elif self.games_list: + # List content is same, just update data for current game + self.current_game = self.games_list[self.current_game_index] + + if not self.games_list: + self.logger.info( + "No relevant recent games found to display." + ) # Changed log prefix + self.current_game = None # Ensure display clears if no games + + except Exception as e: + self.logger.error( + f"Error updating recent games: {e}", exc_info=True + ) # Changed log prefix + # Don't clear current game on error, keep showing last known state + # self.current_game = None # Decide if we want to clear display on error + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the layout for a recently completed game.""" + try: + # Clear the display first to ensure full coverage (like weather plugin does) + if force_clear: + self.display_manager.clear() + + # Use display_manager.matrix dimensions directly to ensure full display coverage + display_width = self.display_manager.matrix.width if hasattr(self.display_manager, 'matrix') and self.display_manager.matrix else self.display_width + display_height = self.display_manager.matrix.height if hasattr(self.display_manager, 'matrix') and self.display_manager.matrix else self.display_height + + main_img = Image.new( + "RGBA", (display_width, display_height), (0, 0, 0, 255) + ) + overlay = Image.new( + "RGBA", (display_width, display_height), (0, 0, 0, 0) + ) + draw_overlay = ImageDraw.Draw(overlay) + + home_logo = self._load_and_resize_logo( + game["home_id"], + game["home_abbr"], + game["home_logo_path"], + game.get("home_logo_url"), + ) + away_logo = self._load_and_resize_logo( + game["away_id"], + game["away_abbr"], + game["away_logo_path"], + game.get("away_logo_url"), + ) + + if not home_logo or not away_logo: + self.logger.error( + f"Failed to load logos for game: {game.get('id')}" + ) # Changed log prefix + # Draw placeholder text if logos fail (similar to live) + draw_final = ImageDraw.Draw(main_img.convert("RGB")) + self._draw_text_with_outline( + draw_final, "Logo Error", (5, 5), self.fonts["status"] + ) + self.display_manager.image = main_img.convert("RGB") + self.display_manager.update_display() + return + + center_y = display_height // 2 + + # MLB-style logo positioning (closer to edges) with layout offsets + home_x = display_width - home_logo.width + 2 + self._get_layout_offset('home_logo', 'x_offset') + home_y = center_y - (home_logo.height // 2) + self._get_layout_offset('home_logo', 'y_offset') + main_img.paste(home_logo, (home_x, home_y), home_logo) + + away_x = -2 + self._get_layout_offset('away_logo', 'x_offset') + away_y = center_y - (away_logo.height // 2) + self._get_layout_offset('away_logo', 'y_offset') + main_img.paste(away_logo, (away_x, away_y), away_logo) + + # Draw Text Elements on Overlay + # Note: Rankings are now handled in the records/rankings section below + + # Final Scores (Centered vertically, same position as live) with layout offsets + home_score = str(game.get("home_score", "0")) + away_score = str(game.get("away_score", "0")) + score_text = f"{away_score}-{home_score}" + score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) + score_x = (display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') + score_y = (display_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') # Centered vertically, same as live games + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + ) + + # Game date (Bottom of display, one line above bottom edge, centered) with layout offsets + # Use same font as upcoming games (time font) for consistency + game_date = game.get("game_date", "") + if game_date: + date_width = draw_overlay.textlength(game_date, font=self.fonts["time"]) + date_x = (display_width - date_width) // 2 + self._get_layout_offset('date', 'x_offset') + # Position date at bottom of display, one line above the bottom edge + date_y = display_height - 7 + self._get_layout_offset('date', 'y_offset') # One line above bottom edge + self._draw_text_with_outline( + draw_overlay, game_date, (date_x, date_y), self.fonts["time"] + ) + + # "Final" text (Top center) with layout offsets + status_text = game.get( + "period_text", "Final" + ) # Use formatted period text (e.g., "Final/OT") or default "Final" + status_width = draw_overlay.textlength(status_text, font=self.fonts["time"]) + status_x = (display_width - status_width) // 2 + self._get_layout_offset('status_text', 'x_offset') + status_y = 1 + self._get_layout_offset('status_text', 'y_offset') + self._draw_text_with_outline( + draw_overlay, status_text, (status_x, status_y), self.fonts["time"] + ) + + # Draw odds if available + if "odds" in game and game["odds"]: + self._draw_dynamic_odds( + draw_overlay, game["odds"], display_width, display_height + ) + + # Draw records or rankings if enabled + if self.show_records or self.show_ranking: + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + self.logger.debug(f"Loaded 6px record font successfully") + except IOError: + record_font = ImageFont.load_default() + self.logger.warning( + f"Failed to load 6px font, using default font (size: {record_font.size})" + ) + + # Get team abbreviations + away_abbr = game.get("away_abbr", "") + home_abbr = game.get("home_abbr", "") + + record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height + self.logger.debug( + f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" + ) + + # Display away team info + if away_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + away_text = "" + elif self.show_ranking: + # Show ranking only if available + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + away_text = "" + elif self.show_records: + # Show record only when rankings are disabled + away_text = game.get("away_record", "") + else: + away_text = "" + + if away_text: + away_record_x = 0 + self.logger.debug( + f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + away_text, + (away_record_x, record_y), + record_font, + ) + + # Display home team info + if home_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + home_text = "" + elif self.show_ranking: + # Show ranking only if available + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + home_text = "" + elif self.show_records: + # Show record only when rankings are disabled + home_text = game.get("home_record", "") + else: + home_text = "" + + if home_text: + home_record_bbox = draw_overlay.textbbox( + (0, 0), home_text, font=record_font + ) + home_record_width = home_record_bbox[2] - home_record_bbox[0] + home_record_x = display_width - home_record_width + self._get_layout_offset('records', 'home_x_offset') + self.logger.debug( + f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + home_text, + (home_record_x, record_y), + record_font, + ) + + self._custom_scorebug_layout(game, draw_overlay) + # Composite and display + main_img = Image.alpha_composite(main_img, overlay) + main_img = main_img.convert("RGB") + # Assign directly like weather plugin does for full display coverage + self.display_manager.image = main_img + self.display_manager.update_display() # Update display here + + except Exception as e: + self.logger.error( + f"Error displaying recent game: {e}", exc_info=True + ) # Changed log prefix + + def display(self, force_clear=False) -> bool: + """Display recent games, handling switching.""" + if not self.is_enabled or not self.games_list: + # If disabled or no games, clear the display so old content doesn't persist + if force_clear or not self.games_list: + self.display_manager.clear() + self.display_manager.update_display() + if not self.games_list and self.current_game: + self.current_game = None # Clear internal state if list becomes empty + return False + + try: + current_time = time.time() + + # Check if it's time to switch games (protected by lock for thread safety) + with self._games_lock: + if ( + len(self.games_list) > 1 + and current_time - self.last_game_switch >= self.game_display_duration + ): + self.current_game_index = (self.current_game_index + 1) % len( + self.games_list + ) + self.current_game = self.games_list[self.current_game_index] + self.last_game_switch = current_time + force_clear = True # Force redraw on switch + + # Log team switching with sport prefix + if self.current_game: + away_abbr = self.current_game.get("away_abbr", "UNK") + home_abbr = self.current_game.get("home_abbr", "UNK") + sport_prefix = ( + self.sport_key.upper() + if hasattr(self, "sport_key") + else "SPORT" + ) + self.logger.info( + f"[{sport_prefix} Recent] Showing {away_abbr} vs {home_abbr}" + ) + else: + self.logger.debug( + f"Switched to game index {self.current_game_index}" + ) + + if self.current_game: + self._draw_scorebug_layout(self.current_game, force_clear) + # update_display() is called within _draw_scorebug_layout for recent + + except Exception as e: + self.logger.error( + f"Error in display loop: {e}", exc_info=True + ) # Changed log prefix + return False + + return True + + +class SportsLive(SportsCore): + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + self.update_interval = self.mode_config.get("live_update_interval", 15) + self.no_data_interval = 300 + # Log the configured interval for debugging + self.logger.info( + f"SportsLive initialized: live_update_interval={self.update_interval}s, " + f"no_data_interval={self.no_data_interval}s, " + f"mode_config keys={list(self.mode_config.keys())}" + ) + self.last_update = 0 + self.live_games = [] + self.current_game_index = 0 + self.last_game_switch = 0 # Will be set to current_time when games are first loaded + self.game_display_duration = self.mode_config.get("live_game_duration", 20) + self.last_display_update = 0 + self.last_log_time = 0 + self.log_interval = 300 + self.last_count_log_time = 0 # Track when we last logged count data + self.count_log_interval = 5 # Only log count data every 5 seconds + # Initialize test_mode - defaults to False (live mode) + self.test_mode = self.mode_config.get("test_mode", False) + # Track game update timestamps for stale data detection + self.game_update_timestamps = {} # {game_id: {"clock": timestamp, "score": timestamp, "last_seen": timestamp}} + self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default + + def _is_game_really_over(self, game: Dict) -> bool: + """Check if a game appears to be over even if API says it's live.""" + game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" + + # Check if period_text indicates final + period_text = game.get("period_text", "").lower() + if "final" in period_text: + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " + f"returning True - 'final' in period_text='{period_text}'" + ) + return True + + # Check if clock is 0:00 in Q4 or OT + # Safely coerce clock to string to handle None or non-string values + raw_clock = game.get("clock") + if raw_clock is None or not isinstance(raw_clock, str): + clock = "0:00" + else: + clock = raw_clock + period = game.get("period", 0) + # Handle various clock formats: "0:00", ":00", "0", ":40" (stuck at :40) + clock_normalized = clock.replace(":", "").strip() + + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " + f"raw_clock={raw_clock!r}, clock='{clock}', clock_normalized='{clock_normalized}', period={period}, period_text='{period_text}'" + ) + + if period >= 4: + # In Q4 or OT, if clock is 0:00 or appears stuck (like :40), consider it over + # Check for clock at 0:00 - various formats: "0:00", ":00", normalized "000"/"00" + # Note: Clocks like ":40", ":50" are legitimate (under 1 minute remaining) + if clock_normalized == "000" or clock_normalized == "00" or clock == "0:00" or clock == ":00": + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " + f"returning True - clock appears to be 0:00 (clock='{clock}', normalized='{clock_normalized}', period={period})" + ) + return True + + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): returning False" + ) + return False + + def _detect_stale_games(self, games: List[Dict]) -> None: + """Remove games that appear stale or haven't updated.""" + current_time = time.time() + + for game in games[:]: # Copy list to iterate safely + game_id = game.get("id") + if not game_id: + continue + + # Check if game data is stale + timestamps = self.game_update_timestamps.get(game_id, {}) + last_seen = timestamps.get("last_seen", 0) + + if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: + self.logger.warning( + f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(last seen {int(current_time - last_seen)}s ago)" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] + continue + + # Also check if game appears to be over + if self._is_game_really_over(game): + self.logger.debug( + f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] + + def update(self): + """Update live game data and handle game switching.""" + if not self.is_enabled: + return + + # Define current_time and interval before the problematic line (originally line 455) + # Ensure 'import time' is present at the top of the file. + current_time = time.time() + + # Define interval using a pattern similar to NFLLiveManager's update method. + # Uses getattr for robustness, assuming attributes for live_games, test_mode, + # no_data_interval, and update_interval are available on self. + _live_games_attr = self.live_games + _test_mode_attr = getattr( + self, 'test_mode', False + ) # test_mode is often from a base class or config - use getattr for safety + _no_data_interval_attr = ( + self.no_data_interval + ) # Default similar to NFLLiveManager + _update_interval_attr = ( + self.update_interval + ) # Default similar to NFLLiveManager + + # For live managers, always use the configured live_update_interval when checking for updates. + # Only use no_data_interval if we've recently checked and confirmed there are no live games. + # This ensures we check for live games frequently even if the list is temporarily empty. + # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) + time_since_last_update = current_time - self.last_update + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + + if _live_games_attr or _test_mode_attr: + # We have live games or are in test mode, use the configured update interval + interval = _update_interval_attr + elif has_recently_checked: + # We've checked recently and found no live games, use longer interval + interval = _no_data_interval_attr + else: + # First check or haven't checked in a while, use update interval to check for live games + interval = _update_interval_attr + + # Original line from traceback (line 455), now with variables defined: + if current_time - self.last_update >= interval: + self.last_update = current_time + + # Fetch rankings if enabled + if self.show_ranking: + self._fetch_team_rankings() + + # Fetch live game data + data = self._fetch_data() + new_live_games = [] + if not data: + self.logger.debug(f"No data returned from _fetch_data() for {self.sport_key}") + elif "events" not in data: + self.logger.debug(f"Data returned but no 'events' key for {self.sport_key}: {list(data.keys()) if isinstance(data, dict) else type(data)}") + elif data and "events" in data: + total_events = len(data["events"]) + self.logger.debug(f"Fetched {total_events} total events from API for {self.sport_key}") + + live_or_halftime_count = 0 + filtered_out_count = 0 + + for game in data["events"]: + details = self._extract_game_details(game) + if details: + # Log game status for debugging - use INFO level to see what's happening + status_state = game.get("competitions", [{}])[0].get("status", {}).get("type", {}).get("state", "unknown") + status_name = game.get("competitions", [{}])[0].get("status", {}).get("type", {}).get("name", "unknown") + self.logger.info( + f"[{self.sport_key.upper()} Live] Game {details.get('away_abbr', '?')}@{details.get('home_abbr', '?')}: " + f"state={status_state}, name={status_name}, is_live={details.get('is_live')}, " + f"is_halftime={details.get('is_halftime')}, is_final={details.get('is_final')}, " + f"clock={details.get('clock', 'N/A')}, period={details.get('period', 'N/A')}, " + f"status_text={details.get('status_text', 'N/A')}" + ) + + # Filter out final games and games that appear to be over + if details.get("is_final", False): + self.logger.info( + f"[{self.sport_key.upper()} Live] Filtered out final game: {details.get('away_abbr')}@{details.get('home_abbr')} " + f"(is_final={details.get('is_final')}, clock={details.get('clock')}, period={details.get('period')})" + ) + continue + + # Additional validation: check if game appears to be over + if self._is_game_really_over(details): + self.logger.info( + f"[{self.sport_key.upper()} Live] Skipping game that appears final: {details.get('away_abbr')}@{details.get('home_abbr')} " + f"(clock={details.get('clock')}, period={details.get('period')}, period_text={details.get('period_text')})" + ) + continue + + # Check if game should be considered live + # First check explicit flags + is_explicitly_live = details["is_live"] or details["is_halftime"] + + # Also check if game appears to be live based on status even if not explicitly marked + # Some APIs may mark games differently (e.g., "in progress" vs "in") + status_text = details.get("status_text", "").upper() + appears_live_by_status = ( + (status_state == "in" and not details.get("is_final", False)) + or (status_name and "in" in status_name.lower() and "progress" in status_name.lower()) + or (status_text and ("Q1" in status_text or "Q2" in status_text or "Q3" in status_text or "Q4" in status_text or "OT" in status_text)) + or (details.get("clock") and details.get("clock") != "" and details.get("clock") != "0:00" and details.get("clock") != ":00") + ) + + is_actually_live = is_explicitly_live or appears_live_by_status + + if is_actually_live: + if appears_live_by_status and not is_explicitly_live: + # Game appears to be live but wasn't explicitly marked as such - log this + self.logger.warning( + f"[{self.sport_key.upper()} Live] Game {details.get('away_abbr')}@{details.get('home_abbr')} " + f"appears live (state={status_state}, name={status_name}, clock={details.get('clock')}) " + f"but is_live={details.get('is_live')}, is_halftime={details.get('is_halftime')} - treating as live" + ) + live_or_halftime_count += 1 + self.logger.info( + f"[{self.sport_key.upper()} Live] Found live/halftime game: {details.get('away_abbr')}@{details.get('home_abbr')} " + f"(is_live={details.get('is_live')}, is_halftime={details.get('is_halftime')}, " + f"state={status_state}, appears_live_by_status={appears_live_by_status})" + ) + + # Track game timestamps for stale detection + game_id = details.get("id") + if game_id: + current_clock = details.get("clock", "") + current_score = f"{details.get('away_score', '0')}-{details.get('home_score', '0')}" + + if game_id not in self.game_update_timestamps: + self.game_update_timestamps[game_id] = {} + + timestamps = self.game_update_timestamps[game_id] + timestamps["last_seen"] = time.time() + + # Track if clock/score changed + if timestamps.get("last_clock") != current_clock: + timestamps["last_clock"] = current_clock + timestamps["clock_changed_at"] = time.time() + if timestamps.get("last_score") != current_score: + timestamps["last_score"] = current_score + timestamps["score_changed_at"] = time.time() + + # Determine if this game should be included based on filtering settings + # Priority: show_all_live > favorite_teams_only (if favorites exist) > show all + game_str = f"{details.get('away_abbr')}@{details.get('home_abbr')}" + home_abbr = details.get("home_abbr") + away_abbr = details.get("away_abbr") + + if self.show_all_live: + # Always show all live games if show_all_live is enabled + should_include = True + include_reason = "show_all_live=True" + elif not self.show_favorite_teams_only: + # If favorite teams filtering is disabled, show all games + should_include = True + include_reason = "show_favorite_teams_only=False" + elif not self.favorite_teams: + # If favorite teams filtering is enabled but no favorites are configured, + # show all games (same behavior as SportsUpcoming) + should_include = True + include_reason = "favorite_teams is empty" + else: + # Favorite teams filtering is enabled AND favorites are configured + # Only show games involving favorite teams + home_match = home_abbr in self.favorite_teams + away_match = away_abbr in self.favorite_teams + should_include = home_match or away_match + include_reason = ( + f"favorite_teams={self.favorite_teams}, " + f"home_abbr='{home_abbr}' in_favorites={home_match}, " + f"away_abbr='{away_abbr}' in_favorites={away_match}" + ) + + self.logger.debug( + f"[LIVE_PRIORITY_DEBUG] {self.sport_key.upper()} filter decision for {game_str}: " + f"should_include={should_include}, reason: {include_reason}" + ) + + if not should_include: + filtered_out_count += 1 + self.logger.info( + f"[{self.sport_key.upper()} Live] Filtered out live game {details.get('away_abbr')}@{details.get('home_abbr')}: " + f"show_all_live={self.show_all_live}, " + f"show_favorite_teams_only={self.show_favorite_teams_only}, " + f"favorite_teams={self.favorite_teams}" + ) + + if should_include: + if self.show_odds: + self._fetch_odds(details) + new_live_games.append(details) + + self.logger.info( + f"[{self.sport_key.upper()} Live] Live game filtering: {total_events} total events, " + f"{live_or_halftime_count} live/halftime, " + f"{filtered_out_count} filtered out, " + f"{len(new_live_games)} included | " + f"show_all_live={self.show_all_live}, " + f"show_favorite_teams_only={self.show_favorite_teams_only}, " + f"favorite_teams={self.favorite_teams if self.favorite_teams else '[] (showing all)'}" + ) + + # Detect and remove stale games + self._detect_stale_games(new_live_games) + + # Log changes or periodically + current_time_for_log = ( + time.time() + ) # Use a consistent time for logging comparison + should_log = ( + current_time_for_log - self.last_log_time >= self.log_interval + or len(new_live_games) != len(self.live_games) + or any( + g1["id"] != g2.get("id") + for g1, g2 in zip(self.live_games, new_live_games) + ) # Check if game IDs changed + or ( + not self.live_games and new_live_games + ) # Log if games appeared + ) + + if should_log: + if new_live_games: + filter_text = ( + "favorite teams" + if self.show_favorite_teams_only or self.show_all_live + else "all teams" + ) + self.logger.info( + f"Found {len(new_live_games)} live/halftime games for {filter_text}." + ) + for ( + game_info + ) in new_live_games: # Renamed game to game_info + self.logger.info( + f" - {game_info['away_abbr']}@{game_info['home_abbr']} ({game_info.get('status_text', 'N/A')})" + ) + else: + filter_text = ( + "favorite teams" + if self.show_favorite_teams_only or self.show_all_live + else "criteria" + ) + self.logger.info( + f"No live/halftime games found for {filter_text}." + ) + self.last_log_time = current_time_for_log + + # Update game list and current game (thread-safe) + with self._games_lock: + if new_live_games: + # Check if the games themselves changed, not just scores/time + new_game_ids = {g["id"] for g in new_live_games} + current_game_ids = {g["id"] for g in self.live_games} + + if new_game_ids != current_game_ids: + self.live_games = sorted( + new_live_games, + key=lambda g: g.get("start_time_utc") + or datetime.now(timezone.utc), + ) # Sort by start time + # Reset index if current game is gone or list is new + if ( + not self.current_game + or self.current_game["id"] not in new_game_ids + ): + self.current_game_index = 0 + self.current_game = ( + self.live_games[0] if self.live_games else None + ) + self.last_game_switch = current_time + else: + # Find current game's new index if it still exists + try: + self.current_game_index = next( + i + for i, g in enumerate(self.live_games) + if g["id"] == self.current_game["id"] + ) + self.current_game = self.live_games[ + self.current_game_index + ] # Update current_game with fresh data + # Fix: Set last_game_switch if it's still 0 (initialized) to prevent immediate switching + if self.last_game_switch == 0: + self.last_game_switch = current_time + except ( + StopIteration + ): # Should not happen if check above passed, but safety first + self.current_game_index = 0 + self.current_game = self.live_games[0] + self.last_game_switch = current_time + + else: + # Just update the data for the existing games + temp_game_dict = {g["id"]: g for g in new_live_games} + self.live_games = [ + temp_game_dict.get(g["id"], g) for g in self.live_games + ] # Update in place + if self.current_game: + self.current_game = temp_game_dict.get( + self.current_game["id"], self.current_game + ) + # Fix: Set last_game_switch if it's still 0 (initialized) to prevent immediate switching + # This handles the case where games were loaded previously but last_game_switch was never set + if self.last_game_switch == 0: + self.last_game_switch = current_time + + # Display update handled by main loop based on interval + + else: + # No live games found + if self.live_games: # Were there games before? + self.logger.info( + "Live games previously showing have ended or are no longer live." + ) # Changed log prefix + self.live_games = [] + self.current_game = None + self.current_game_index = 0 + + else: + # Error fetching data or no events + if self.live_games: # Were there games before? + self.logger.warning( + "Could not fetch update; keeping existing live game data for now." + ) # Changed log prefix + else: + self.logger.warning( + "Could not fetch data and no existing live games." + ) # Changed log prefix + self.current_game = None # Clear current game if fetch fails and no games were active + + # Handle game switching (outside test mode check, thread-safe) + # Fix: Don't check for switching if last_game_switch is still 0 (games haven't been loaded yet) + # This prevents immediate switching when the system has been running for a while before games load + with self._games_lock: + if ( + not self.test_mode + and len(self.live_games) > 1 + and self.last_game_switch > 0 + and (current_time - self.last_game_switch) >= self.game_display_duration + ): + self.current_game_index = (self.current_game_index + 1) % len( + self.live_games + ) + self.current_game = self.live_games[self.current_game_index] + self.last_game_switch = current_time + self.logger.info( + f"Switched live view to: {self.current_game['away_abbr']}@{self.current_game['home_abbr']}" + ) # Changed log prefix + # Force display update via flag or direct call if needed, but usually let main loop handle diff --git a/plugin-repos/ufc-scoreboard/ufc_managers.py b/plugin-repos/ufc-scoreboard/ufc_managers.py new file mode 100644 index 000000000..ae0055158 --- /dev/null +++ b/plugin-repos/ufc-scoreboard/ufc_managers.py @@ -0,0 +1,250 @@ +"""UFC Manager Classes - Adapted from original work by Alex Resnick (legoguy1000) - PR #137""" + +import logging +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +import requests + +from mma import MMA, MMALive, MMARecent, MMAUpcoming + +# Constants +ESPN_UFC_SCOREBOARD_URL = ( + "https://site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard" +) + + +class BaseUFCManager(MMA): + """Base class for UFC managers with common functionality.""" + + # Class variables for warning tracking + _no_data_warning_logged = False + _last_warning_time = 0 + _warning_cooldown = 60 # Only log warnings once per minute + _shared_data = None + _last_shared_update = 0 + + def __init__( + self, + config: Dict[str, Any], + display_manager: Any, + cache_manager: Any, + ): + self.logger = logging.getLogger("UFC") + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="ufc_scoreboard", + ) + + self.league = "ufc" + self.sport = "mma" + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("ufc_recent", False) + self.upcoming_enabled = display_modes.get("ufc_upcoming", False) + self.live_enabled = display_modes.get("ufc_live", False) + + self.logger.info( + f"Initialized UFC manager with display dimensions: " + f"{self.display_width}x{self.display_height}" + ) + self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info( + f"Display modes - Recent: {self.recent_enabled}, " + f"Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}" + ) + + def _fetch_ufc_api_data(self, use_cache: bool = True) -> Optional[Dict]: + """ + Fetches the full season schedule for UFC using background threading. + Returns cached data immediately if available, otherwise starts background fetch. + """ + now = datetime.now(timezone.utc) + season_year = now.year + datestring = f"{season_year}0101-{season_year}1231" + cache_key = f"{self.sport_key}_schedule_{season_year}" + + # Check cache first + if use_cache: + cached_data = self.cache_manager.get(cache_key) + if cached_data: + # Validate cached data structure + if isinstance(cached_data, dict) and "events" in cached_data: + self.logger.info(f"Using cached schedule for {season_year}") + return cached_data + elif isinstance(cached_data, list): + # Handle old cache format (list of events) + self.logger.info( + f"Using cached schedule for {season_year} (legacy format)" + ) + return {"events": cached_data} + else: + self.logger.warning( + f"Invalid cached data format for {season_year}: " + f"{type(cached_data)}" + ) + # Clear invalid cache + self.cache_manager.clear_cache(cache_key) + + # Synchronous fallback when background service is not available + if not self.background_enabled: + try: + response = self.session.get( + ESPN_UFC_SCOREBOARD_URL, + params={"dates": datestring, "limit": 1000}, + headers=self.headers, + timeout=30, + ) + response.raise_for_status() + data = response.json() + self.cache_manager.set(cache_key, data) + return data + except Exception as e: + self.logger.error(f"Sync fetch failed: {e}") + return None + + # Start background fetch + self.logger.info( + f"Starting background fetch for {season_year} season schedule..." + ) + + def fetch_callback(result): + """Callback when background fetch completes.""" + if result.success: + self.logger.info( + f"Background fetch completed for {season_year}: " + f"{len(result.data.get('events'))} events" + ) + else: + self.logger.error( + f"Background fetch failed for {season_year}: {result.error}" + ) + + # Clean up request tracking + if season_year in self.background_fetch_requests: + del self.background_fetch_requests[season_year] + + # Get background service configuration + background_config = self.mode_config.get("background_service", {}) + timeout = background_config.get("request_timeout", 30) + max_retries = background_config.get("max_retries", 3) + priority = background_config.get("priority", 2) + + # Submit background fetch request + request_id = self.background_service.submit_fetch_request( + sport="mma", + year=season_year, + url=ESPN_UFC_SCOREBOARD_URL, + cache_key=cache_key, + params={"dates": datestring, "limit": 1000}, + headers=self.headers, + timeout=timeout, + max_retries=max_retries, + priority=priority, + callback=fetch_callback, + ) + + # Track the request + self.background_fetch_requests[season_year] = request_id + + # For immediate response, try to get partial data + partial_data = self._get_weeks_data() + if partial_data: + return partial_data + + return None + + def _fetch_data(self) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, UFCLiveManager): + # Live games should fetch only current games, not entire season + return self._fetch_todays_games() + else: + # Recent and Upcoming managers should use cached season data + return self._fetch_ufc_api_data(use_cache=True) + + +class UFCLiveManager(BaseUFCManager, MMALive): + """Manager for live UFC fights.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: Any, + cache_manager: Any, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("UFCLiveManager") + + if self.test_mode: + self.current_game = { + "id": "test001", + "event_id": "test_event001", + "comp_id": "test001", + "fighter1_id": "12345", + "fighter1_name": "Israel Adesanya", + "fighter1_name_short": "I. Adesanya", + "fighter1_image_path": Path(self.logo_dir, "12345.png"), + "fighter1_image_url": "https://a.espncdn.com/combiner/i?img=/i/headshots/mma/players/full/12345.png", + "fighter1_record": "24-3-0", + "fighter2_id": "67890", + "fighter2_name": "Dricus Du Plessis", + "fighter2_name_short": "D. Du Plessis", + "fighter2_image_path": Path(self.logo_dir, "67890.png"), + "fighter2_image_url": "https://a.espncdn.com/combiner/i?img=/i/headshots/mma/players/full/67890.png", + "fighter2_record": "21-2-0", + "fight_class": "MW", + "status_text": "R2 3:45", + "is_live": True, + "is_final": False, + "is_upcoming": False, + "is_period_break": False, + "start_time_utc": None, + } + self.live_games = [self.current_game] + self.logger.info( + "Initialized UFCLiveManager with test game: " + "Adesanya vs Du Plessis" + ) + else: + self.logger.info("Initialized UFCLiveManager in live mode") + + +class UFCRecentManager(BaseUFCManager, MMARecent): + """Manager for recently completed UFC fights.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: Any, + cache_manager: Any, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("UFCRecentManager") + self.logger.info( + f"Initialized UFCRecentManager with " + f"{len(self.favorite_fighters)} favorite fighters" + ) + + +class UFCUpcomingManager(BaseUFCManager, MMAUpcoming): + """Manager for upcoming UFC fights.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: Any, + cache_manager: Any, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("UFCUpcomingManager") + self.logger.info( + f"Initialized UFCUpcomingManager with " + f"{len(self.favorite_fighters)} favorite fighters" + )