diff --git a/plugins.json b/plugins.json index fe57d4ea..3f8d4765 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-04-03", + "last_updated": "2026-04-06", "plugins": [ { "id": "hello-world", @@ -717,6 +717,29 @@ "verified": true, "screenshot": "", "latest_version": "1.0.0" + }, + { + "id": "lacrosse-scoreboard", + "name": "Lacrosse Scoreboard", + "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", + "author": "ChuckBuilds", + "category": "sports", + "tags": [ + "lacrosse", + "ncaa", + "sports", + "scoreboard", + "live-scores" + ], + "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins", + "branch": "main", + "plugin_path": "plugins/lacrosse-scoreboard", + "stars": 0, + "downloads": 0, + "last_updated": "2026-04-06", + "verified": true, + "screenshot": "", + "latest_version": "1.0.3" } ] } diff --git a/plugins/lacrosse-scoreboard/LICENSE b/plugins/lacrosse-scoreboard/LICENSE new file mode 100644 index 00000000..e653a0c1 --- /dev/null +++ b/plugins/lacrosse-scoreboard/LICENSE @@ -0,0 +1,17 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright (C) 2025 LEDMatrix Team + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/plugins/lacrosse-scoreboard/README.md b/plugins/lacrosse-scoreboard/README.md new file mode 100644 index 00000000..335e3b47 --- /dev/null +++ b/plugins/lacrosse-scoreboard/README.md @@ -0,0 +1,210 @@ +# Lacrosse Scoreboard Plugin + +Live, recent, and upcoming NCAA Men's and Women's Lacrosse games on your LEDMatrix display. Real-time scores, schedules, favorite-team filtering, live-game priority, poll-rank badges, and both switch and scroll display modes — modeled on the existing hockey scoreboard plugin. + +## Features + +- **NCAA Men's Lacrosse** (Inside Lacrosse D1 Poll — top 20) +- **NCAA Women's Lacrosse** (Inside Lacrosse / IWLCA Coaches Top 25 Poll) +- **Live games** with quarter, clock, score, and optional shot totals +- **Recent (completed) games** with final score and OT indicator +- **Upcoming games** with start time, matchup, records, and rankings +- **Favorite team filtering** — pin specific teams, or use the dynamic shortcuts `NCAA_MENS_TOP_20`, `NCAA_MENS_TOP_10`, `NCAA_MENS_TOP_5`, `NCAA_WOMENS_TOP_25`, `NCAA_WOMENS_TOP_10`, `NCAA_WOMENS_TOP_5` to auto-track whichever teams are currently in the poll +- **Live priority** — force live favorite-team games to preempt the rotation +- **Per-mode display style** — `switch` (one game card rotating) or `scroll` (horizontal ticker), independently configurable for live, recent, and upcoming +- **Poll rank badges** — `#1`, `#2` overlays on team names, updated hourly from ESPN's public rankings feed +- **Element customization** — toggle records, rankings, odds, shot totals; override layout offsets for logos, score, and status text +- **Configurable durations, update intervals, and game counts** per league + +## Requirements + +- Python 3.9+ +- LEDMatrix core 2.0.0 or newer +- A minimum display of 64×32 (128×32 recommended for full scroll and scoreboard layouts) +- Internet access to reach the public ESPN API + +No API key is required. + +## Installation + +The plugin is installable from the LEDMatrix plugin store — search for **Lacrosse Scoreboard** and enable it. On first launch, team logos for any teams appearing in the current scoreboard window will be downloaded to `assets/sports/ncaa_logos/` automatically. + +To install manually from source: + +```bash +cd /path/to/LEDMatrix +python -m pip install --user pillow requests pytz # see requirements.txt +cp -r /path/to/ledmatrix-plugins/plugins/lacrosse-scoreboard plugins/ +``` + +Then add a `lacrosse-scoreboard` entry to your LEDMatrix `config.json` (see **Configuration** below) and restart the LEDMatrix service. + +## Dependencies + +From `requirements.txt`: + +- `Pillow>=9.0.0` — image compositing and logo rendering +- `requests>=2.28.0` — ESPN API calls +- `pytz>=2022.1` — timezone conversion for game start times +- `urllib3>=1.26.0` — HTTP retry logic + +All dependencies are standard and already present in a typical LEDMatrix install. + +## Configuration + +The plugin config is split into per-league blocks. See `config_schema.json` for the authoritative list of fields and their defaults. Minimal working example: + +```json +{ + "enabled": true, + "defaults": { + "display_duration": 15, + "show_records": true, + "show_ranking": true, + "show_odds": false + }, + "ncaa_mens": { + "enabled": true, + "display_modes": { + "live": true, + "live_display_mode": "switch", + "recent": true, + "recent_display_mode": "scroll", + "upcoming": true, + "upcoming_display_mode": "scroll" + }, + "teams": { + "favorite_teams": ["NCAA_MENS_TOP_10", "JOHNS HOPKINS"], + "favorite_teams_only": false, + "show_all_live": true + }, + "filtering": { + "recent_games_to_show": 5, + "upcoming_games_to_show": 10 + }, + "live_priority": true + }, + "ncaa_womens": { + "enabled": true, + "display_modes": { + "live": true, + "live_display_mode": "switch", + "recent": true, + "recent_display_mode": "scroll", + "upcoming": true, + "upcoming_display_mode": "scroll" + }, + "teams": { + "favorite_teams": ["MARYLAND", "NORTH CAROLINA", "SYRACUSE"], + "favorite_teams_only": false, + "show_all_live": true + } + } +} +``` + +### Display modes per league + +Each of live / recent / upcoming can be independently enabled and given its own display style: + +- `switch` — one game card at a time, rotating on a timer +- `scroll` — all matching games composited into a horizontal ticker that scrolls across the display + +### Live priority + +When `live_priority: true`, live games for configured favorite teams will interrupt the normal rotation whenever they are in progress. + +## Team Abbreviations + +**Important — NCAA lacrosse uses full-name abbreviations, not the short codes you may be used to from the football, basketball, or hockey plugins.** ESPN's lacrosse feed returns team abbreviations like `NORTH CAROLINA`, `JOHNS HOPKINS`, `SAINT JOSEPH'S`, not `UNC` / `JHU` / `SJU`. Use the full-name form in `favorite_teams` or the matching will fail silently. + +A few recurring examples (use exactly as shown, uppercase, with spaces, apostrophes, and periods as they appear): + +| Team | Abbreviation | +|---|---| +| Maryland | `MARYLAND` | +| North Carolina | `NORTH CAROLINA` | +| Syracuse | `SYRACUSE` | +| Johns Hopkins | `JOHNS HOPKINS` | +| Duke | `DUKE` | +| Notre Dame | `NOTRE DAME` | +| Princeton | `PRINCETON` | +| Virginia | `VIRGINIA` | +| Yale | `YALE` | +| Harvard | `HARVARD` | +| Cornell | `CORNELL` | +| Penn State | `PENN STATE` | +| Richmond | `RICHMOND` | +| Saint Joseph's | `SAINT JOSEPH'S` | +| Mount St. Mary's | `MOUNT ST. MARY'S` | +| William & Mary | `WILLIAM & MARY` | +| Long Island University | `LONG ISLAND UNIVERSI` *(ESPN truncates to 20 chars)* | + +If you're unsure of a team's exact abbreviation, hit the ESPN scoreboard endpoint directly and look at `events[].competitions[].competitors[].team.abbreviation`: + +```bash +curl -s 'https://site.api.espn.com/apis/site/v2/sports/lacrosse/mens-college-lacrosse/scoreboard' \ + | python -m json.tool | grep -A1 abbreviation +``` + +### Dynamic team shortcuts + +Instead of listing abbreviations manually, use one of these tokens in `favorite_teams` to auto-expand to the current poll: + +| Token | League | Expands to | +|---|---|---| +| `NCAA_MENS_TOP_5` | Men's | Top 5 of Inside Lacrosse D1 Men's Poll | +| `NCAA_MENS_TOP_10` | Men's | Top 10 of Inside Lacrosse D1 Men's Poll | +| `NCAA_MENS_TOP_20` | Men's | Full top 20 (the entire men's poll) | +| `NCAA_WOMENS_TOP_5` | Women's | Top 5 of IWLCA Coaches Poll | +| `NCAA_WOMENS_TOP_10` | Women's | Top 10 of IWLCA Coaches Poll | +| `NCAA_WOMENS_TOP_25` | Women's | Full top 25 | + +Tokens can be mixed with literal abbreviations: `["NCAA_MENS_TOP_10", "JOHNS HOPKINS", "PRINCETON"]` tracks the current top 10 *plus* any of those two teams that aren't already in it. + +## Display Modes (plugin-level) + +The plugin exposes six granular display modes the LEDMatrix host rotation can cycle through: + +- `ncaa_mens_live`, `ncaa_mens_recent`, `ncaa_mens_upcoming` +- `ncaa_womens_live`, `ncaa_womens_recent`, `ncaa_womens_upcoming` + +## Data Source + +Scores and schedules come from ESPN's public site API: + +- Men's scoreboard: `https://site.api.espn.com/apis/site/v2/sports/lacrosse/mens-college-lacrosse/scoreboard` +- Men's rankings: `https://site.api.espn.com/apis/site/v2/sports/lacrosse/mens-college-lacrosse/rankings` +- Women's scoreboard: `https://site.api.espn.com/apis/site/v2/sports/lacrosse/womens-college-lacrosse/scoreboard` +- Women's rankings: `https://site.api.espn.com/apis/site/v2/sports/lacrosse/womens-college-lacrosse/rankings` + +Team logos are fetched from `https://a.espncdn.com/i/teamlogos/ncaa/500/{team_id}.png` and cached locally under `assets/sports/ncaa_logos/`. + +## Troubleshooting + +**My favorite team doesn't show up.** You're almost certainly using a short abbreviation like `UNC` or `JHU`. Lacrosse abbreviations are the full school name in uppercase — see **Team Abbreviations** above. + +**No games appear at all.** NCAA lacrosse is a spring sport. Men's runs roughly January through late May; women's runs February through late May. Outside that window, the ESPN scoreboard endpoint returns an empty `events[]` array and the plugin has nothing to display. + +**Rank badges (`#1`, `#2`) aren't appearing.** Ensure `display_options.show_ranking: true` (the default). Rankings are cached for 1 hour and are only populated for teams that appear in the current poll. Unranked teams show no badge, which is intentional. + +**Shot totals are always 0.** ESPN's lacrosse feed does not currently expose per-team shot counts in the `competitors[].statistics` array the way hockey does for saves. The `show_shots` toggle is wired but will remain empty until ESPN publishes the stat. Leave it off for now. + +**Tournament games show `TBD` placeholders.** ESPN uses team IDs `-1` and `-2` for bracket slots where the opponent hasn't been determined yet. The plugin renders these as text placeholders — they'll resolve to real logos once the bracket is set. + +**A team's logo is missing or looks wrong.** Delete the cached logo at `assets/sports/ncaa_logos/{ABBR}.png` (use the exact file name, spaces and all) and the plugin will re-download it from ESPN on the next update. + +## Testing + +A standalone smoke test is included at `test_lacrosse_plugin.py`: + +```bash +cd plugins/lacrosse-scoreboard +python test_lacrosse_plugin.py +``` + +It stubs the LEDMatrix host modules, imports every plugin module, exercises the dynamic team resolver against live ESPN rankings, and runs a 50-event window of both men's and women's scoreboard data through `Lacrosse._extract_game_details`, asserting that required fields are populated. No external test framework is required. + +## License + +See `LICENSE` in this directory. diff --git a/plugins/lacrosse-scoreboard/base_odds_manager.py b/plugins/lacrosse-scoreboard/base_odds_manager.py new file mode 100644 index 00000000..35b18270 --- /dev/null +++ b/plugins/lacrosse-scoreboard/base_odds_manager.py @@ -0,0 +1,293 @@ +""" +BaseOddsManager - Base class for odds data fetching and management. + +This base class provides core odds fetching functionality that can be inherited +by plugins that need odds data (odds ticker, scoreboards, etc.). + +Follows LEDMatrix configuration management patterns: +- Single responsibility: Data fetching only +- Reusable: Other plugins can inherit from it +- Clean configuration: Separate config sections +- Maintainable: Changes to odds logic affect all plugins +""" + +import time +import logging +import requests +import json +from datetime import datetime, timedelta, timezone +from typing import Dict, Any, Optional, List +import pytz + +# 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 + + Plugins can inherit from this class to get odds functionality. + """ + + def __init__(self, cache_manager, config_manager=None): + """ + Initialize the base odds manager. + + Args: + cache_manager: Cache manager instance for data persistence + config_manager: Configuration manager (optional) + """ + 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 + + # 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.logger.debug( + f"BaseOddsManager configuration loaded: " + f"update_interval={self.update_interval}s, " + f"timeout={self.request_timeout}s" + ) + + except Exception as e: + self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}") + + def get_odds( + self, + sport: str | None, + league: str | None, + event_id: str, + update_interval_seconds: Optional[int] = None, + ) -> Optional[Dict[str, Any]]: + """ + Fetch odds data for a specific game. + + Args: + sport: Sport name (e.g., 'football', 'basketball') + league: League name (e.g., 'nfl', 'nba') + event_id: ESPN 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: + raise ValueError("Sport and League cannot be None") + + # Use provided interval or default + interval = update_interval_seconds or self.update_interval + cache_key = f"odds_espn_{sport}_{league}_{event_id}" + + # Check cache first + cached_data = self.cache_manager.get(cache_key) + + if cached_data: + # Filter out the "no_odds" marker - it should not be returned + # as valid odds data. Treat it as a cache miss so a fresh API + # call is made once the cache entry expires. + 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 = { + "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}/events/{event_id}/competitions/{event_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 game") + + if odds_data: + self.cache_manager.set(cache_key, odds_data, ttl=interval) + 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}, ttl=interval) + + return odds_data + + except requests.exceptions.RequestException as e: + self.logger.exception(f"Error fetching odds from ESPN API for {cache_key}") + except json.JSONDecodeError: + self.logger.exception( + f"Error decoding JSON response from ESPN API for {cache_key}" + ) + + # Return cached odds on error, but filter out the no_odds sentinel + cached = self.cache_manager.get(cache_key) + if isinstance(cached, dict) and cached.get("no_odds"): + return None + return cached + + def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Extract and format odds data from ESPN API response. + + 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())}") + + # The ESPN API returns odds data directly in the item, not in a providers array + # Extract the odds data directly from the item + extracted_data = { + "details": item.get("details"), + "over_under": item.get("overUnder"), + "spread": item.get("spread"), + "home_team_odds": { + "money_line": item.get("homeTeamOdds", {}).get("moneyLine"), + "spread_odds": item.get("homeTeamOdds", {}) + .get("current", {}) + .get("pointSpread", {}) + .get("value"), + }, + "away_team_odds": { + "money_line": item.get("awayTeamOdds", {}).get("moneyLine"), + "spread_odds": item.get("awayTeamOdds", {}) + .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 or an unexpected structure + if ( + "count" in data + and data["count"] == 0 + and "items" in data + and data["items"] == [] + ): + # This is a valid empty response - no odds available for this game + self.logger.debug("Valid empty response - no odds available for this game") + 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], + update_interval_seconds: Optional[int] = None, + ) -> Dict[str, Dict[str, Any]]: + """ + Fetch odds data for multiple games. + + Args: + sport: Sport name + league: League name + event_ids: List of ESPN event IDs + update_interval_seconds: Override default update interval + + Returns: + Dictionary mapping event_id to odds data + """ + results = {} + + for event_id in event_ids: + try: + odds_data = self.get_odds( + sport, league, event_id, update_interval_seconds + ) + if odds_data: + results[event_id] = odds_data + except Exception as e: + self.logger.error(f"Error fetching odds for event {event_id}: {e}") + continue + + return results + + def clear_cache(self, sport: Optional[str] = None, league: Optional[str] = None, event_id: Optional[str] = None): + """ + Clear odds cache for specific criteria. + + Args: + sport: Sport name (optional) + league: League name (optional) + event_id: Event ID (optional) + """ + if sport and league and event_id: + # Clear specific event + cache_key = f"odds_espn_{sport}_{league}_{event_id}" + self.cache_manager.clear_cache(cache_key) + self.logger.info(f"Cleared cache for {cache_key}") + else: + # Clear all odds cache + self.cache_manager.clear_cache() + self.logger.info("Cleared all cache") diff --git a/plugins/lacrosse-scoreboard/config_schema.json b/plugins/lacrosse-scoreboard/config_schema.json new file mode 100644 index 00000000..3457179f --- /dev/null +++ b/plugins/lacrosse-scoreboard/config_schema.json @@ -0,0 +1,1136 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Lacrosse Scoreboard Configuration", + "description": "Configuration schema for the Lacrosse Scoreboard plugin (NCAA Men's and Women's Lacrosse)", + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable or disable the lacrosse scoreboard plugin" + }, + "defaults": { + "type": "object", + "description": "Default settings that can be inherited by leagues (can be overridden per league)", + "properties": { + "display_duration": { + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Default duration in seconds to display each game" + }, + "show_records": { + "type": "boolean", + "default": false, + "description": "Default setting to show team records (wins-losses)" + }, + "show_ranking": { + "type": "boolean", + "default": false, + "description": "Default setting to show team rankings (when available)" + }, + "show_odds": { + "type": "boolean", + "default": false, + "description": "Default setting to show betting odds for games" + }, + "update_interval_seconds": { + "type": "integer", + "default": 3600, + "minimum": 30, + "maximum": 86400, + "description": "Default base update interval in seconds for fetching game data" + }, + "season_cache_duration_seconds": { + "type": "integer", + "default": 86400, + "minimum": 3600, + "maximum": 604800, + "description": "How long to cache season data (seconds)" + }, + "show_shots": { + "type": "boolean", + "title": "Show shot totals", + "description": "Display shot totals when available from ESPN", + "default": false + } + } + }, + "ncaa_mens": { + "type": "object", + "description": "NCAA Men's Lacrosse configuration", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable NCAA Men's Lacrosse games" + }, + "display_modes": { + "type": "object", + "title": "Display Modes", + "description": "Control which game types to show and how they are displayed", + "properties": { + "live": { + "type": "boolean", + "default": true, + "description": "Show NCAA Men's Lacrosse live games" + }, + "live_display_mode": { + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent": { + "type": "boolean", + "default": true, + "description": "Show NCAA Men's Lacrosse recent games" + }, + "recent_display_mode": { + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming": { + "type": "boolean", + "default": true, + "description": "Show NCAA Men's Lacrosse upcoming games" + }, + "upcoming_display_mode": { + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + } + } + }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "type": "boolean", + "default": true, + "description": "Show league icons ( shield, NCAA logos) between different leagues" + }, + "dynamic_duration": { + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." + } + } + }, + "teams": { + "type": "object", + "description": "Team filtering and favorites configuration", + "properties": { + "favorite_teams": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "NCAA Men's Lacrosse favorite team abbreviations (e.g., ['BU', 'BC', 'MICH'])" + }, + "favorite_teams_only": { + "type": "boolean", + "default": false, + "description": "Only show NCAA Men's Lacrosse games with favorite teams" + }, + "show_all_live": { + "type": "boolean", + "default": false, + "description": "Show all live NCAA Men's Lacrosse games, not just favorites" + } + } + }, + "filtering": { + "type": "object", + "description": "Game filtering and quantity settings", + "properties": { + "recent_games_to_show": { + "type": "integer", + "default": 5, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + }, + "upcoming_games_to_show": { + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 50, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + } + } + }, + "update_intervals": { + "type": "object", + "description": "Data update frequency settings (in seconds)", + "properties": { + "base": { + "type": "integer", + "default": 300, + "minimum": 60, + "maximum": 900, + "description": "Base update interval for NCAA Men's Lacrosse data" + }, + "live": { + "type": "integer", + "default": 60, + "minimum": 10, + "maximum": 300, + "description": "Update interval for live NCAA Men's Lacrosse games" + }, + "recent": { + "type": "integer", + "default": 3600, + "minimum": 60, + "maximum": 86400, + "description": "Update interval for recent NCAA Men's Lacrosse games" + }, + "upcoming": { + "type": "integer", + "default": 3600, + "minimum": 60, + "maximum": 86400, + "description": "Update interval for upcoming NCAA Men's Lacrosse games" + }, + "odds": { + "type": "integer", + "default": 3600, + "minimum": 60, + "maximum": 86400, + "description": "Update interval for NCAA Men's Lacrosse betting odds data" + } + } + }, + "display_durations": { + "type": "object", + "description": "How long to display games on screen (in seconds)", + "properties": { + "base": { + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Base display duration for NCAA Men's Lacrosse games" + }, + "live": { + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 120, + "description": "Display duration for live NCAA Men's Lacrosse games" + }, + "recent": { + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Display duration for recent NCAA Men's Lacrosse games" + }, + "upcoming": { + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Display duration for upcoming NCAA Men's Lacrosse games" + } + } + }, + "display_options": { + "type": "object", + "description": "What information to display on the scoreboard", + "properties": { + "show_records": { + "type": "boolean", + "default": true, + "description": "Show NCAA Men's Lacrosse team records (wins-losses)" + }, + "show_ranking": { + "type": "boolean", + "default": true, + "description": "Show NCAA Men's Lacrosse team rankings/standings" + }, + "show_odds": { + "type": "boolean", + "default": false, + "description": "Show betting odds for NCAA Men's Lacrosse games" + }, + "show_shots": { + "type": "boolean", + "title": "Show shot totals", + "description": "Display shot totals when available from ESPN", + "default": false + } + } + }, + "mode_durations": { + "type": "object", + "title": "Mode-Level Durations", + "description": "Control total duration for each mode type for NCAA Men's Lacrosse. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "properties": { + "recent_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Recent mode before rotating to next mode. Default: null (uses dynamic calculation). Set to null to use dynamic calculation. When mode cycles back, continues from last game shown (no repetition)." + }, + "upcoming_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Upcoming mode before rotating to next mode. Default: null (uses dynamic calculation). Set to null to use dynamic calculation. When mode cycles back, continues from last game shown (no repetition)." + }, + "live_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Live mode before rotating to next mode. Default: null (uses dynamic calculation). Set to null to use dynamic calculation. When mode cycles back, continues from last game shown (no repetition)." + } + } + }, + "live_priority": { + "type": "boolean", + "default": false, + "description": "Prioritize live NCAA Men's Lacrosse games over scheduled games" + }, + "dynamic_duration": { + "type": "object", + "description": "Dynamic duration settings - automatically adjust display time based on content", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Men's Lacrosse games" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum duration in seconds when dynamic duration is enabled" + }, + "modes": { + "type": "object", + "description": "Per-mode dynamic duration settings", + "properties": { + "live": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Men's Lacrosse live games" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum duration for NCAA Men's Lacrosse live games" + } + } + }, + "recent": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Men's Lacrosse recent games" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum duration for NCAA Men's Lacrosse recent games" + } + } + }, + "upcoming": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Men's Lacrosse upcoming games" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum duration for NCAA Men's Lacrosse upcoming games" + } + } + } + } + } + } + } + } + }, + "ncaa_womens": { + "type": "object", + "description": "NCAA Women's Lacrosse configuration", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable NCAA Women's Lacrosse games" + }, + "display_modes": { + "type": "object", + "title": "Display Modes", + "description": "Control which game types to show and how they are displayed", + "properties": { + "live": { + "type": "boolean", + "default": true, + "description": "Show NCAA Women's Lacrosse live games" + }, + "live_display_mode": { + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent": { + "type": "boolean", + "default": true, + "description": "Show NCAA Women's Lacrosse recent games" + }, + "recent_display_mode": { + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming": { + "type": "boolean", + "default": true, + "description": "Show NCAA Women's Lacrosse upcoming games" + }, + "upcoming_display_mode": { + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + } + } + }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "type": "boolean", + "default": true, + "description": "Show league icons ( shield, NCAA logos) between different leagues" + }, + "dynamic_duration": { + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." + } + } + }, + "teams": { + "type": "object", + "description": "Team filtering and favorites configuration", + "properties": { + "favorite_teams": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "NCAA Women's Lacrosse favorite team abbreviations (e.g., ['WISC', 'MINN', 'OSU'])" + }, + "favorite_teams_only": { + "type": "boolean", + "default": false, + "description": "Only show NCAA Women's Lacrosse games with favorite teams" + }, + "show_all_live": { + "type": "boolean", + "default": false, + "description": "Show all live NCAA Women's Lacrosse games, not just favorites" + } + } + }, + "filtering": { + "type": "object", + "description": "Game filtering and quantity settings", + "properties": { + "recent_games_to_show": { + "type": "integer", + "default": 5, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + }, + "upcoming_games_to_show": { + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 50, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + } + } + }, + "update_intervals": { + "type": "object", + "description": "Data update frequency settings (in seconds)", + "properties": { + "base": { + "type": "integer", + "default": 300, + "minimum": 60, + "maximum": 900, + "description": "Base update interval for NCAA Women's Lacrosse data" + }, + "live": { + "type": "integer", + "default": 60, + "minimum": 10, + "maximum": 300, + "description": "Update interval for live NCAA Women's Lacrosse games" + }, + "recent": { + "type": "integer", + "default": 3600, + "minimum": 60, + "maximum": 86400, + "description": "Update interval for recent NCAA Women's Lacrosse games" + }, + "upcoming": { + "type": "integer", + "default": 3600, + "minimum": 60, + "maximum": 86400, + "description": "Update interval for upcoming NCAA Women's Lacrosse games" + }, + "odds": { + "type": "integer", + "default": 3600, + "minimum": 60, + "maximum": 86400, + "description": "Update interval for NCAA Women's Lacrosse betting odds data" + } + } + }, + "display_durations": { + "type": "object", + "description": "How long to display games on screen (in seconds)", + "properties": { + "base": { + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Base display duration for NCAA Women's Lacrosse games" + }, + "live": { + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 120, + "description": "Display duration for live NCAA Women's Lacrosse games" + }, + "recent": { + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Display duration for recent NCAA Women's Lacrosse games" + }, + "upcoming": { + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Display duration for upcoming NCAA Women's Lacrosse games" + } + } + }, + "display_options": { + "type": "object", + "description": "What information to display on the scoreboard", + "properties": { + "show_records": { + "type": "boolean", + "default": true, + "description": "Show NCAA Women's Lacrosse team records (wins-losses)" + }, + "show_ranking": { + "type": "boolean", + "default": true, + "description": "Show NCAA Women's Lacrosse team rankings/standings" + }, + "show_odds": { + "type": "boolean", + "default": false, + "description": "Show betting odds for NCAA Women's Lacrosse games" + }, + "show_shots": { + "type": "boolean", + "title": "Show shot totals", + "description": "Display shot totals when available from ESPN", + "default": false + } + } + }, + "mode_durations": { + "type": "object", + "title": "Mode-Level Durations", + "description": "Control total duration for each mode type for NCAA Women's Lacrosse. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "properties": { + "recent_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Recent mode before rotating to next mode. Default: null (uses dynamic calculation). Set to null to use dynamic calculation. When mode cycles back, continues from last game shown (no repetition)." + }, + "upcoming_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Upcoming mode before rotating to next mode. Default: null (uses dynamic calculation). Set to null to use dynamic calculation. When mode cycles back, continues from last game shown (no repetition)." + }, + "live_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Live mode before rotating to next mode. Default: null (uses dynamic calculation). Set to null to use dynamic calculation. When mode cycles back, continues from last game shown (no repetition)." + } + } + }, + "live_priority": { + "type": "boolean", + "default": false, + "description": "Prioritize live NCAA Women's Lacrosse games over scheduled games" + }, + "dynamic_duration": { + "type": "object", + "description": "Dynamic duration settings - automatically adjust display time based on content", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Women's Lacrosse games" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum duration in seconds when dynamic duration is enabled" + }, + "modes": { + "type": "object", + "description": "Per-mode dynamic duration settings", + "properties": { + "live": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Women's Lacrosse live games" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum duration for NCAA Women's Lacrosse live games" + } + } + }, + "recent": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Women's Lacrosse recent games" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum duration for NCAA Women's Lacrosse recent games" + } + } + }, + "upcoming": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for NCAA Women's Lacrosse upcoming games" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Maximum duration for NCAA Women's Lacrosse upcoming games" + } + } + } + } + } + } + } + } + }, + "customization": { + "type": "object", + "title": "Display Customization", + "description": "Customize fonts for different text elements on the scoreboard", + "properties": { + "score_text": { + "type": "object", + "title": "Game Score", + "description": "Font settings for the game score display", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use for scores (only TTF fonts supported)", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 10 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "period_text": { + "type": "object", + "title": "Period/Clock", + "description": "Font settings for period, clock, and time remaining text", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use (only TTF fonts supported)", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 8 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "team_name": { + "type": "object", + "title": "Team Names", + "description": "Font settings for team name abbreviations", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use (only TTF fonts supported)", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 8 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "status_text": { + "type": "object", + "title": "Status Messages", + "description": "Font settings for status text (e.g., 'Next Game', 'Final')", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use (only TTF fonts supported)", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], + "default": "4x6-font.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 6 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "detail_text": { + "type": "object", + "title": "Details/Odds", + "description": "Font settings for odds and other detail information", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use (only TTF fonts supported)", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], + "default": "4x6-font.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 6 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "rank_text": { + "type": "object", + "title": "Rankings", + "description": "Font settings for ranking displays", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use (only TTF fonts supported)", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 10 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "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": { + "home_logo": { + "type": "object", + "title": "Home Team Logo", + "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 + }, + "away_logo": { + "type": "object", + "title": "Away Team Logo", + "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 + }, + "score": { + "type": "object", + "title": "Game Score", + "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 + }, + "status_text": { + "type": "object", + "title": "Status/Period 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 + }, + "date": { + "type": "object", + "title": "Game 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": "Game 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": "Records/Rankings", + "properties": { + "away_x_offset": { + "type": "integer", + "default": 0, + "description": "Away team record horizontal offset from left (default: 0)" + }, + "home_x_offset": { + "type": "integer", + "default": 0, + "description": "Home team record horizontal offset from right (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from bottom (default: 0)" + } + }, + "additionalProperties": false + } + }, + "x-propertyOrder": [ + "home_logo", + "away_logo", + "score", + "status_text", + "date", + "time", + "records" + ], + "additionalProperties": false + } + }, + "x-propertyOrder": [ + "score_text", + "period_text", + "team_name", + "status_text", + "detail_text", + "rank_text", + "layout" + ], + "additionalProperties": false + } + }, + "required": [ + "enabled" + ] +} diff --git a/plugins/lacrosse-scoreboard/data_sources.py b/plugins/lacrosse-scoreboard/data_sources.py new file mode 100644 index 00000000..1f3dd813 --- /dev/null +++ b/plugins/lacrosse-scoreboard/data_sources.py @@ -0,0 +1,291 @@ +""" +Pluggable Data Source Architecture + +This module provides abstract data sources that can be plugged into the sports system +to support different APIs and data providers. +""" + +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.""" + try: + url = f"{self.base_url}/{sport}/{league}/rankings" + response = self.session.get(url, headers=self.get_headers(), timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug(f"Fetched standings for {sport}/{league}") + return data + + except Exception as e: + self.logger.error(f"Error fetching standings from ESPN: {e}") + return {} + + +class MLBAPIDataSource(DataSource): + """MLB API data source.""" + + def __init__(self, logger: logging.Logger): + super().__init__(logger) + self.base_url = "https://statsapi.mlb.com/api/v1" + + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: + """Fetch live games from MLB API.""" + try: + url = f"{self.base_url}/schedule" + params = { + 'sportId': 1, # MLB + 'date': datetime.now().strftime('%Y-%m-%d'), + 'hydrate': 'game,team,venue,weather' + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + games = data.get('dates', [{}])[0].get('games', []) + + # Filter for live games + live_games = [game for game in games + if game.get('status', {}).get('abstractGameState') == 'Live'] + + self.logger.debug(f"Fetched {len(live_games)} live games from MLB API") + return live_games + + except Exception as e: + self.logger.error(f"Error fetching live games from MLB API: {e}") + return [] + + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: + """Fetch schedule from MLB API.""" + try: + start_date, end_date = date_range + url = f"{self.base_url}/schedule" + + params = { + 'sportId': 1, # MLB + 'startDate': start_date.strftime('%Y-%m-%d'), + 'endDate': end_date.strftime('%Y-%m-%d'), + 'hydrate': 'game,team,venue' + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + all_games = [] + for date_data in data.get('dates', []): + all_games.extend(date_data.get('games', [])) + + self.logger.debug(f"Fetched {len(all_games)} scheduled games from MLB API") + return all_games + + except Exception as e: + self.logger.error(f"Error fetching schedule from MLB API: {e}") + return [] + + def fetch_standings(self, sport: str, league: str) -> Dict: + """Fetch standings from MLB API.""" + try: + url = f"{self.base_url}/standings" + params = { + 'leagueId': 103, # American League + 'season': datetime.now().year, + 'standingsType': 'regularSeason' + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug(f"Fetched standings from MLB API") + return data + + except Exception as e: + self.logger.error(f"Error fetching standings from MLB API: {e}") + return {} + + +class SoccerAPIDataSource(DataSource): + """Soccer API data source (generic structure).""" + + def __init__(self, logger: logging.Logger, api_key: str = None): + super().__init__(logger) + self.api_key = api_key + self.base_url = "https://api.football-data.org/v4" # Example API + + def get_headers(self) -> Dict[str, str]: + """Get headers with API key for soccer API.""" + headers = super().get_headers() + if self.api_key: + headers['X-Auth-Token'] = self.api_key + return headers + + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: + """Fetch live games from soccer API.""" + try: + # This would need to be adapted based on the specific soccer API + url = f"{self.base_url}/matches" + params = { + 'status': 'LIVE', + 'competition': league + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + matches = data.get('matches', []) + + self.logger.debug(f"Fetched {len(matches)} live games from soccer API") + return matches + + except Exception as e: + self.logger.error(f"Error fetching live games from soccer API: {e}") + return [] + + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: + """Fetch schedule from soccer API.""" + try: + start_date, end_date = date_range + url = f"{self.base_url}/matches" + + params = { + 'competition': league, + 'dateFrom': start_date.strftime('%Y-%m-%d'), + 'dateTo': end_date.strftime('%Y-%m-%d') + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + matches = data.get('matches', []) + + self.logger.debug(f"Fetched {len(matches)} scheduled games from soccer API") + return matches + + except Exception as e: + self.logger.error(f"Error fetching schedule from soccer API: {e}") + return [] + + def fetch_standings(self, sport: str, league: str) -> Dict: + """Fetch standings from soccer API.""" + try: + url = f"{self.base_url}/competitions/{league}/standings" + response = self.session.get(url, headers=self.get_headers(), timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug(f"Fetched standings from soccer API") + return data + + except Exception as e: + self.logger.error(f"Error fetching standings from soccer API: {e}") + return {} + + +# Factory function removed - sport classes now instantiate data sources directly diff --git a/plugins/lacrosse-scoreboard/dynamic_team_resolver.py b/plugins/lacrosse-scoreboard/dynamic_team_resolver.py new file mode 100644 index 00000000..37d54f4a --- /dev/null +++ b/plugins/lacrosse-scoreboard/dynamic_team_resolver.py @@ -0,0 +1,204 @@ +""" +Simplified DynamicTeamResolver for plugin use +""" + +import logging +import time +import requests +from typing import Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +class DynamicTeamResolver: + """ + Simplified resolver for dynamic team names to actual team abbreviations. + + This class handles special team names that represent dynamic groups + like AP Top 25 rankings, which update automatically. + """ + + # Cache for rankings data. Each entry is keyed by (sport, token) and + # carries its own fetched-at timestamp so different tokens age + # independently. Historically this was a single shared timestamp, which + # meant fetching one token could extend the apparent freshness of + # unrelated ones. + _rankings_cache: Dict[str, Tuple[List[str], float]] = {} + _cache_duration: int = 3600 # 1 hour cache + + # Supported dynamic team patterns. + # + # NCAA men's lacrosse is ranked via the Inside Lacrosse Division I Men's + # Lacrosse Poll (20 teams). NCAA women's lacrosse is ranked via the + # Inside Lacrosse/IWLCA Coaches Top 25 Poll. Both are exposed by ESPN at + # /sports/lacrosse/{mens,womens}-college-lacrosse/rankings + DYNAMIC_PATTERNS = { + # NCAA Men's Lacrosse (Inside Lacrosse Poll — top 20) + 'NCAA_MENS_TOP_20': {'sport': 'ncaa_mens_lacrosse', 'limit': 20}, + 'NCAA_MENS_TOP_10': {'sport': 'ncaa_mens_lacrosse', 'limit': 10}, + 'NCAA_MENS_TOP_5': {'sport': 'ncaa_mens_lacrosse', 'limit': 5}, + # NCAA Women's Lacrosse (Inside Lacrosse/IWLCA Top 25 Poll) + 'NCAA_WOMENS_TOP_25': {'sport': 'ncaa_womens_lacrosse', 'limit': 25}, + 'NCAA_WOMENS_TOP_10': {'sport': 'ncaa_womens_lacrosse', 'limit': 10}, + 'NCAA_WOMENS_TOP_5': {'sport': 'ncaa_womens_lacrosse', 'limit': 5}, + } + + def __init__(self, request_timeout: int = 30): + """Initialize the dynamic team resolver.""" + self.request_timeout = request_timeout + self.logger = logger + + def resolve_teams(self, team_list: List[str], sport: str = 'ncaam_lacrosse') -> List[str]: + """ + Resolve a list of team names, expanding dynamic team names. + + Args: + team_list: List of team names (can include dynamic names like + "NCAA_MENS_TOP_20" or "NCAA_WOMENS_TOP_25") + sport: Sport type for context (default: 'ncaam_lacrosse'). The pattern + itself carries the endpoint key, so this argument is informational. + + Returns: + List of resolved team abbreviations + """ + if not team_list: + return [] + + resolved_teams = [] + + for team in team_list: + if team in self.DYNAMIC_PATTERNS: + # Resolve dynamic team + dynamic_teams = self._resolve_dynamic_team(team, sport) + resolved_teams.extend(dynamic_teams) + self.logger.info(f"Resolved {team} to {len(dynamic_teams)} teams: {dynamic_teams[:5]}{'...' if len(dynamic_teams) > 5 else ''}") + elif self._is_potential_dynamic_team(team): + # Unknown dynamic team, skip it + self.logger.warning(f"Unknown dynamic team '{team}' - skipping") + else: + # Regular team name, add as-is + resolved_teams.append(team) + + # Remove duplicates while preserving order + seen = set() + unique_teams = [] + for team in resolved_teams: + if team not in seen: + seen.add(team) + unique_teams.append(team) + + return unique_teams + + def _resolve_dynamic_team(self, dynamic_team: str, sport: str) -> List[str]: + """ + Resolve a dynamic team name to actual team abbreviations. + + Args: + dynamic_team: Dynamic team name (e.g., "AP_TOP_25") + sport: Sport type for context + + Returns: + List of team abbreviations + """ + try: + pattern_config = self.DYNAMIC_PATTERNS[dynamic_team] + pattern_sport = pattern_config['sport'] + limit = pattern_config['limit'] + + # Check cache first (per-token TTL) + cache_key = f"{pattern_sport}_{dynamic_team}" + entry = self._rankings_cache.get(cache_key) + if entry is not None: + cached_teams, cached_at = entry + if cached_teams and (time.time() - cached_at) < self._cache_duration: + self.logger.debug(f"Using cached {dynamic_team} teams") + return cached_teams[:limit] + + # Fetch fresh rankings + rankings = self._fetch_rankings(pattern_sport) + if rankings: + # Cache the results with this token's own timestamp + self._rankings_cache[cache_key] = (rankings, time.time()) + + self.logger.info(f"Fetched {len(rankings)} teams for {dynamic_team}") + return rankings[:limit] + else: + self.logger.warning(f"Failed to fetch rankings for {dynamic_team}") + return [] + + except Exception as e: + self.logger.error(f"Error resolving dynamic team {dynamic_team}: {e}") + return [] + + def _fetch_rankings(self, sport: str) -> List[str]: + """ + Fetch current rankings from ESPN API. + + Args: + sport: Sport type (e.g., 'ncaa_fb', 'ncaa_mens_lacrosse', 'ncaa_womens_lacrosse') + + Returns: + List of team abbreviations in ranking order + """ + try: + # Map sport to ESPN API endpoint + sport_mapping = { + 'ncaa_mens_lacrosse': 'lacrosse/mens-college-lacrosse/rankings', + 'ncaa_womens_lacrosse': 'lacrosse/womens-college-lacrosse/rankings', + } + + endpoint = sport_mapping.get(sport) + if not endpoint: + self.logger.warning(f"Unsupported sport for rankings: {sport} - rankings may not be available") + return [] + + url = f"https://site.api.espn.com/apis/site/v2/sports/{endpoint}" + + headers = { + 'User-Agent': 'LEDMatrix/1.0', + 'Accept': 'application/json' + } + + response = requests.get(url, headers=headers, timeout=self.request_timeout) + response.raise_for_status() + + data = response.json() + + # Extract team abbreviations from rankings + teams = [] + if 'rankings' in data and data['rankings']: + ranking = data['rankings'][0] # Use first ranking (usually AP) + if 'ranks' in ranking: + for rank_item in ranking['ranks']: + team_info = rank_item.get('team', {}) + abbr = team_info.get('abbreviation', '') + if abbr: + teams.append(abbr) + elif 'teams' in data: + # Alternative format - try to extract from teams array if rankings structure differs + for team_item in data.get('teams', []): + abbr = team_item.get('abbreviation', '') + if abbr: + teams.append(abbr) + + if teams: + self.logger.debug(f"Fetched {len(teams)} ranked teams for {sport}") + else: + self.logger.debug(f"No rankings found for {sport} (API may not support lacrosse rankings yet)") + return teams + + except requests.exceptions.RequestException as e: + # API may not support lacrosse rankings yet - this is expected + self.logger.debug(f"API request failed for {sport} rankings (may not be available): {e}") + return [] + except Exception as e: + self.logger.debug(f"Error fetching rankings for {sport}: {e}") + return [] + + def _is_potential_dynamic_team(self, team: str) -> bool: + """Check if a team name looks like a dynamic team pattern.""" + return ( + team.startswith('AP_') + or team.startswith('TOP_') + or team.startswith('NCAA_MENS_') + or team.startswith('NCAA_WOMENS_') + ) diff --git a/plugins/lacrosse-scoreboard/game_renderer.py b/plugins/lacrosse-scoreboard/game_renderer.py new file mode 100644 index 00000000..34c12fcb --- /dev/null +++ b/plugins/lacrosse-scoreboard/game_renderer.py @@ -0,0 +1,559 @@ +""" +Game Renderer for Lacrosse Scoreboard Plugin + +Extracts game rendering logic into a reusable component for scroll display mode. +Returns PIL Images instead of updating display directly. +""" + +import logging +import os +from pathlib import Path +from typing import Dict, Any, Optional, Tuple +from PIL import Image, ImageDraw, ImageFont + +logger = logging.getLogger(__name__) + +# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 +# Fall back to Image.LANCZOS for older versions +try: + RESAMPLE_FILTER = Image.Resampling.LANCZOS +except AttributeError: + RESAMPLE_FILTER = Image.LANCZOS + + +class GameRenderer: + """ + Renders individual game cards as PIL Images for display. + + This class extracts the rendering logic from the sports manager classes + to provide a reusable component for both switch and scroll display modes. + """ + + def __init__( + self, + display_width: int, + display_height: int, + config: Dict[str, Any], + logo_cache: Optional[Dict[str, Image.Image]] = None, + custom_logger: Optional[logging.Logger] = None + ): + """ + Initialize the GameRenderer. + + Args: + display_width: Width of the display/game card + display_height: Height of the display/game card + config: Configuration dictionary + logo_cache: Optional shared logo cache dictionary + custom_logger: Optional custom logger instance + """ + self.display_width = display_width + self.display_height = display_height + self.config = config + self.logger = custom_logger or logger + + # Shared logo cache for performance + self._logo_cache = logo_cache if logo_cache is not None else {} + + # Load fonts + self.fonts = self._load_fonts() + + # Get logo directories from config + self.logo_dirs = { + 'ncaa_mens': config.get('ncaa_mens', {}).get('logo_dir', 'assets/sports/ncaa_logos'), + 'ncaa_womens': config.get('ncaa_womens', {}).get('logo_dir', 'assets/sports/ncaa_logos'), + 'ncaam_lacrosse': config.get('ncaa_mens', {}).get('logo_dir', 'assets/sports/ncaa_logos'), + 'ncaaw_lacrosse': config.get('ncaa_womens', {}).get('logo_dir', 'assets/sports/ncaa_logos'), + } + + # Display options + defaults = config.get('defaults', {}) + self.show_records = defaults.get('show_records', config.get('show_records', False)) + self.show_ranking = defaults.get('show_ranking', config.get('show_ranking', False)) + + # Rankings cache (populated externally) + self._team_rankings_cache: Dict[str, int] = {} + + def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: + """Load fonts used by the scoreboard from config or use defaults.""" + fonts = {} + + # Get customization config + 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(score_config, default_size=10) + fonts["time"] = self._load_custom_font(period_config, default_size=8) + fonts["team"] = self._load_custom_font(team_config, default_size=8) + fonts["status"] = self._load_custom_font(status_config, default_size=6) + fonts["detail"] = self._load_custom_font(detail_config, default_size=6) + fonts["rank"] = self._load_custom_font(rank_config, default_size=10) + self.logger.debug("Successfully loaded fonts from config") + except Exception: + self.logger.exception("Error loading fonts, 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.") + default_font = ImageFont.load_default() + fonts = {k: default_font for k in ["score", "time", "team", "status", "detail", "rank"]} + + return fonts + + def _load_custom_font(self, element_config: Dict[str, Any], default_size: int = 8) -> ImageFont.FreeTypeFont: + """Load a custom font from an element configuration dictionary.""" + font_name = element_config.get('font', 'PressStart2P-Regular.ttf') + font_size = int(element_config.get('font_size', default_size)) + font_path = os.path.join('assets', 'fonts', font_name) + + try: + if os.path.exists(font_path): + if font_path.lower().endswith(('.ttf', '.otf')): + return ImageFont.truetype(font_path, font_size) + elif font_path.lower().endswith('.bdf'): + # ImageFont.truetype does not support bitmap (BDF) fonts. + # Use ImageFont.load for BDFs; note that BDFs are bitmap + # fonts and ignore font_size — the glyph size is baked in. + try: + return ImageFont.load(font_path) + except Exception as e: + self.logger.warning( + f"Could not load BDF font {font_name}: {e}; using default" + ) + except Exception as e: + self.logger.error(f"Error loading font {font_name}: {e}") + + # Fallback 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) + except Exception: + self.logger.debug(f"Could not load fallback font from {default_font_path}") + + return ImageFont.load_default() + + def set_rankings_cache(self, rankings: Dict[str, int]) -> None: + """Set the team rankings cache for display.""" + self._team_rankings_cache = rankings + + def preload_logos(self, games: list, logo_dir: Path) -> None: + """ + Pre-load team logos for all games to improve scroll performance. + + Args: + games: List of game dictionaries + logo_dir: Path to logo directory + """ + for game in games: + league = game.get('league', 'ncaa_mens') + for team_key in ['home_abbr', 'away_abbr']: + abbr = game.get(team_key, '') + # Use league-aware cache key to avoid collisions across leagues + cache_key = f"{league}_{abbr}" + if abbr and cache_key not in self._logo_cache: + # Get logo path from game or resolve from logo_dir + logo_path_str = game.get(f'{team_key.replace("abbr", "logo_path")}') + if logo_path_str: + # Resolve relative paths using logo_dir + logo_path = Path(logo_path_str) if os.path.isabs(logo_path_str) else logo_dir / logo_path_str + else: + logo_path = logo_dir / f"{abbr}.png" + + # _load_and_resize_logo handles caching with league-aware key internally + self._load_and_resize_logo(abbr, logo_path, league) + + self.logger.debug(f"Preloaded {len(self._logo_cache)} team logos") + + def _get_logo_path(self, league: str, team_abbrev: str) -> Path: + """Get the logo path for a team based on league.""" + logo_dir = self.logo_dirs.get(league, 'assets/sports/ncaa_logos') + return Path(logo_dir) / f"{team_abbrev}.png" + + def _load_and_resize_logo( + self, + team_abbrev: str, + logo_path: Optional[Path] = None, + league: str = 'ncaa_mens' + ) -> Optional[Image.Image]: + """Load and resize a team logo with caching.""" + cache_key = f"{league}_{team_abbrev}" + if cache_key in self._logo_cache: + return self._logo_cache[cache_key] + + # Also check without league prefix for backward compatibility + if team_abbrev in self._logo_cache: + return self._logo_cache[team_abbrev] + + try: + # Use provided path or get from league config + if logo_path is None or not os.path.exists(logo_path): + logo_path = self._get_logo_path(league, team_abbrev) + + if logo_path and os.path.exists(logo_path): + # Use context manager to ensure file handle is closed + with Image.open(logo_path) as logo_file: + # Convert creates a copy; if already RGBA, use copy() to detach from file + if logo_file.mode != "RGBA": + logo = logo_file.convert("RGBA") + else: + logo = logo_file.copy() + + # Crop transparent padding then scale so ink fills display_height. + # thumbnail into a display_height square box preserves aspect ratio + # and prevents wide logos from exceeding their half-card slot. + bbox = logo.getbbox() + if bbox: + logo = logo.crop(bbox) + logo.thumbnail((self.display_height, self.display_height), RESAMPLE_FILTER) + + self._logo_cache[cache_key] = logo + return logo + else: + self.logger.debug(f"Logo not found at {logo_path}") + return None + + except Exception as e: + self.logger.error(f"Error loading logo for {team_abbrev}: {e}") + return None + + def _draw_text_with_outline( + self, + draw: ImageDraw.Draw, + text: str, + position: Tuple[int, int], + font: ImageFont.FreeTypeFont, + fill: Tuple[int, int, int] = (255, 255, 255), + outline_color: Tuple[int, int, int] = (0, 0, 0) + ) -> None: + """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 _normalize_game_payload(self, game: Dict[str, Any]) -> Dict[str, Any]: + """ + Normalize flat game payload fields into nested structure. + + This allows render_game_card to work with both flat payloads + (home_abbr, home_score, away_abbr, away_score at top level) and + nested payloads (home_team/away_team/status dicts). + + Args: + game: Game dictionary (flat or nested format) + + Returns: + Game dictionary with normalized nested structure + """ + # Create a copy to avoid mutating the original + normalized = dict(game) + + # Check if we have flat fields that need normalization + has_flat_fields = any( + key in normalized for key in [ + 'home_abbr', 'home_score', 'away_abbr', 'away_score', + 'home_name', 'away_name', 'home_record', 'away_record' + ] + ) + + if not has_flat_fields: + # Already in nested format or empty, return as-is + return normalized + + # Normalize home_team + home_team = normalized.get('home_team', {}) + if not isinstance(home_team, dict): + home_team = {} + # Only set values if they exist at top level and not already in nested dict + if 'home_abbr' in normalized and not home_team.get('abbrev'): + home_team['abbrev'] = normalized.get('home_abbr', '') + if 'home_score' in normalized and 'score' not in home_team: + home_team['score'] = normalized.get('home_score', '0') + if 'home_name' in normalized and not home_team.get('name'): + home_team['name'] = normalized.get('home_name', '') + if 'home_record' in normalized and not home_team.get('record'): + home_team['record'] = normalized.get('home_record', '') + normalized['home_team'] = home_team + + # Normalize away_team + away_team = normalized.get('away_team', {}) + if not isinstance(away_team, dict): + away_team = {} + if 'away_abbr' in normalized and not away_team.get('abbrev'): + away_team['abbrev'] = normalized.get('away_abbr', '') + if 'away_score' in normalized and 'score' not in away_team: + away_team['score'] = normalized.get('away_score', '0') + if 'away_name' in normalized and not away_team.get('name'): + away_team['name'] = normalized.get('away_name', '') + if 'away_record' in normalized and not away_team.get('record'): + away_team['record'] = normalized.get('away_record', '') + normalized['away_team'] = away_team + + # Normalize status + status = normalized.get('status', {}) + if not isinstance(status, dict): + status = {} + if 'status_text' in normalized and not status.get('detail'): + status['detail'] = normalized.get('status_text', '') + if 'period' in normalized and not status.get('period'): + status['period'] = normalized.get('period', '') + if 'clock' in normalized and not status.get('clock'): + status['clock'] = normalized.get('clock', '') + # Mirror into display_clock so the live renderer (which reads + # status['display_clock']) sees flat-payload clocks correctly. + if status.get('clock') and not status.get('display_clock'): + status['display_clock'] = status['clock'] + if 'state' in normalized and not status.get('state'): + status['state'] = normalized.get('state', '') + normalized['status'] = status + + return normalized + + def render_game_card( + self, + game: Dict[str, Any], + game_type: str = "live" + ) -> Image.Image: + """ + Render a single game card as a PIL Image. + + Args: + game: Game dictionary with team info, scores, status, etc. + game_type: Type of game - 'live', 'recent', or 'upcoming' + + Returns: + PIL Image of the rendered game card + """ + # Normalize flat payload fields into nested structure if needed + # This allows render_game_card to work with both flat and nested game dicts + game = self._normalize_game_payload(game) + + # Create base image + 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) + + # Get league for logo directory + league = game.get('league', 'ncaa_mens') + logo_dir = Path(self.logo_dirs.get(league, 'assets/sports/ncaa_logos')) + + # Get team info (home_team/away_team dicts) + home_team = game.get('home_team', {}) + away_team = game.get('away_team', {}) + home_abbr = home_team.get('abbrev', '') + away_abbr = away_team.get('abbrev', '') + + # Load logos + home_logo = self._load_and_resize_logo( + home_abbr, + logo_dir / f"{home_abbr}.png", + league + ) + away_logo = self._load_and_resize_logo( + away_abbr, + logo_dir / f"{away_abbr}.png", + league + ) + + if not home_logo or not away_logo: + return self._render_error_card(f"{away_abbr or '?'}@{home_abbr or '?'}") + + center_y = self.display_height // 2 + + # Draw logos — each centered within a slot on its side; cap at half the card + # width so home_slot_start stays non-negative on square/tall displays + logo_slot = min(self.display_height, self.display_width // 2) + away_x = (logo_slot - away_logo.width) // 2 + away_y = center_y - (away_logo.height // 2) + main_img.paste(away_logo, (away_x, away_y), away_logo) + + home_slot_start = self.display_width - logo_slot + home_x = home_slot_start + (logo_slot - home_logo.width) // 2 + home_y = center_y - (home_logo.height // 2) + main_img.paste(home_logo, (home_x, home_y), home_logo) + + # Draw scores (centered) - only for live and recent games + if game_type in ("live", "recent"): + home_score = str(home_team.get("score", "0")) + away_score = str(away_team.get("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 + score_y = (self.display_height // 2) - 3 + self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) + elif game_type == "upcoming": + # Draw "VS" for upcoming games + vs_text = "VS" + vs_width = draw_overlay.textlength(vs_text, font=self.fonts['score']) + vs_x = (self.display_width - vs_width) // 2 + vs_y = (self.display_height // 2) - 3 + self._draw_text_with_outline(draw_overlay, vs_text, (vs_x, vs_y), self.fonts['score']) + + # Draw period/status based on game type + if game_type == "live": + self._draw_live_game_status(draw_overlay, game) + elif game_type == "recent": + self._draw_recent_game_status(draw_overlay, game) + elif game_type == "upcoming": + self._draw_upcoming_game_status(draw_overlay, game) + + # Draw records or rankings if enabled + if self.show_records or self.show_ranking: + self._draw_records_or_rankings(draw_overlay, game) + + # Composite the overlay onto main image + main_img = Image.alpha_composite(main_img, overlay) + return main_img.convert('RGB') + + def _render_error_card(self, message: str) -> Image.Image: + """Render an error message card.""" + img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) + draw = ImageDraw.Draw(img) + self._draw_text_with_outline(draw, message, (5, 5), self.fonts['status']) + return img + + def _draw_live_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: + """Draw status elements for a live game.""" + # Period and Clock (Top center) + status = game.get('status', {}) + period = status.get('period', 0) + # Prefer display_clock (nested ESPN payload), fall back to clock + # (flat payload normalized in _normalize_game_payload). + clock = status.get('display_clock') or status.get('clock', '') + state = status.get('state', '') + + if state == 'in': + period_clock_text = f"P{period} {clock}".strip() + elif state == 'post': + period_clock_text = "Final" + else: + period_clock_text = status.get('short_detail', '') + + status_width = draw.textlength(period_clock_text, font=self.fonts['time']) + status_x = (self.display_width - status_width) // 2 + status_y = 1 + self._draw_text_with_outline(draw, period_clock_text, (status_x, status_y), self.fonts['time']) + + # Draw shots on goal (optional) + league = game.get('league', 'ncaa_mens') + show_shots = self.config.get(league, {}).get('show_shots', False) + if show_shots: + shots_font = self.fonts['detail'] + home_shots = str(game.get("home_shots", "0")) + away_shots = str(game.get("away_shots", "0")) + shots_text = f"{away_shots} SHOTS {home_shots}" + shots_bbox = draw.textbbox((0, 0), shots_text, font=shots_font) + shots_height = shots_bbox[3] - shots_bbox[1] + shots_y = self.display_height - shots_height - 1 + shots_width = draw.textlength(shots_text, font=shots_font) + shots_x = (self.display_width - shots_width) // 2 + self._draw_text_with_outline(draw, shots_text, (shots_x, shots_y), shots_font) + + def _draw_recent_game_status(self, draw: ImageDraw.Draw, _game: Dict) -> None: + """Draw status elements for a recently completed game. + + Note: _game parameter reserved for future enhancements (e.g., OT indicator). + """ + # Final status (Top center) + status_text = "Final" + status_width = draw.textlength(status_text, font=self.fonts['time']) + status_x = (self.display_width - status_width) // 2 + status_y = 1 + self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['time']) + + def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: + """Draw status elements for an upcoming game.""" + # Get game time from status + status = game.get('status', {}) + game_time = status.get('short_detail', '') + + if game_time: + time_width = draw.textlength(game_time, font=self.fonts['time']) + time_x = (self.display_width - time_width) // 2 + time_y = 1 + self._draw_text_with_outline(draw, game_time, (time_x, time_y), self.fonts['time']) + else: + # Fallback: try to parse start_time + start_time = game.get("start_time", "") + if start_time: + try: + from datetime import datetime, timezone + + dt = datetime.fromisoformat(start_time.replace('Z', '+00:00')) + local_dt = dt.astimezone(timezone.utc) # Use UTC for now + + game_date = local_dt.strftime("%b %d") + + date_width = draw.textlength(game_date, font=self.fonts['time']) + date_x = (self.display_width - date_width) // 2 + date_y = 1 + self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['time']) + except (ValueError, TypeError) as e: + self.logger.debug(f"Failed to parse start_time '{start_time}': {e}") + + def _draw_records_or_rankings(self, draw: ImageDraw.Draw, game: Dict) -> None: + """Draw team records or rankings.""" + # Use configurable detail font, with fallback to hardcoded default + record_font = self.fonts.get('detail') + if record_font is None: + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + except IOError: + record_font = ImageFont.load_default() + + # Get team info (home_team/away_team dicts) + home_team = game.get('home_team', {}) + away_team = game.get('away_team', {}) + away_abbr = away_team.get('abbrev', '') + home_abbr = home_team.get('abbrev', '') + away_record = away_team.get('record', '') + home_record = home_team.get('record', '') + + record_bbox = draw.textbbox((0, 0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height - 4 + + # Away team info + if away_abbr: + away_text = self._get_team_display_text(away_abbr, away_record) + if away_text: + away_record_x = 3 + self._draw_text_with_outline(draw, away_text, (away_record_x, record_y), record_font) + + # Home team info + if home_abbr: + home_text = self._get_team_display_text(home_abbr, home_record) + if home_text: + home_record_bbox = draw.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 - 3 + self._draw_text_with_outline(draw, home_text, (home_record_x, record_y), record_font) + + def _get_team_display_text(self, abbr: str, record: str) -> str: + """Get the display text for a team (ranking or record). + + Rankings take precedence over records when both are enabled. + """ + if self.show_ranking: + rank = self._team_rankings_cache.get(abbr, 0) + if rank > 0: + return f"#{rank}" + return '' + if self.show_records: + return record + return '' diff --git a/plugins/lacrosse-scoreboard/lacrosse.py b/plugins/lacrosse-scoreboard/lacrosse.py new file mode 100644 index 00000000..c2c65390 --- /dev/null +++ b/plugins/lacrosse-scoreboard/lacrosse.py @@ -0,0 +1,421 @@ +import logging +from datetime import datetime +from typing import Any, Dict, Optional + +import pytz +from PIL import Image, ImageDraw, ImageFont + +from data_sources import ESPNDataSource +from sports import SportsCore, SportsLive + + +class Lacrosse(SportsCore): + """Base class for lacrosse 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 = "lacrosse" + self.show_shots = self.mode_config.get("show_shots", False) + + def _fetch_season_schedule( + self, + *, + sport: str, + cache_key_prefix: str, + scoreboard_url: str, + season_start_mmdd: str, + season_end_mmdd: str = "0601", + use_cache: bool = True, + ) -> Optional[Dict]: + """Fetch the full ESPN lacrosse season schedule with caching. + + Shared helper used by both NCAA men's and women's managers. Handles + season-year rollover (anything from July onward targets the next + calendar year's season), cache hits, background fetches, and + immediate partial-data returns. + + Args: + sport: Sport identifier passed to the background fetch service + (e.g. ``"ncaa_mens_lacrosse"`` or ``"ncaa_womens_lacrosse"``). + cache_key_prefix: Cache key prefix; the season year is appended. + scoreboard_url: Full ESPN scoreboard URL for this league. + season_start_mmdd: First calendar date of the season window as + ``"MMDD"`` (e.g. ``"0101"`` for men's, ``"0201"`` for women's). + season_end_mmdd: Last calendar date of the season window; defaults + to June 1 which covers NCAA championships for both leagues. + use_cache: When True, consult the cache manager first and only + kick off a background fetch on a miss. + """ + now = datetime.now(pytz.utc) + season_year = now.year + # After the NCAA championship (late May / June), roll to the next + # season year for caching purposes. + if now.month >= 7: + season_year = now.year + 1 + datestring = f"{season_year}{season_start_mmdd}-{season_year}{season_end_mmdd}" + cache_key = f"{cache_key_prefix}_{season_year}" + + if use_cache: + cached_data = self.cache_manager.get(cache_key) + if cached_data: + 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): + 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}: {type(cached_data)}" + ) + self.cache_manager.clear_cache(cache_key) + + self.logger.info( + f"Fetching full {season_year} season schedule from ESPN API..." + ) + self.logger.info( + f"Starting background fetch for {season_year} season schedule..." + ) + + def fetch_callback(result): + """Callback when background fetch completes.""" + if result.success: + events = (getattr(result, "data", None) or {}).get("events") or [] + self.logger.info( + f"Background fetch completed for {season_year}: {len(events)} events" + ) + else: + self.logger.error( + f"Background fetch failed for {season_year}: {result.error}" + ) + if season_year in self.background_fetch_requests: + del self.background_fetch_requests[season_year] + + 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) + + request_id = self.background_service.submit_fetch_request( + sport=sport, + year=season_year, + url=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, + ) + self.background_fetch_requests[season_year] = request_id + + # For immediate response, return whatever partial data is available. + partial_data = self._get_weeks_data() + if partial_data: + return partial_data + return None + + def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: + """Extract relevant game details from ESPN Lacrosse API response.""" + details, home_team, away_team, status, _situation = ( + self._extract_game_details_common(game_event) + ) + if details is None or home_team is None or away_team is None or status is None: + return None + try: + competition = game_event["competitions"][0] + status = competition["status"] + + # Lacrosse shot totals (if exposed in statistics) + home_stats = home_team.get("statistics", []) + away_stats = away_team.get("statistics", []) + home_shots = next( + ( + int(c["displayValue"]) + for c in home_stats + if c.get("name") in ("shots", "totalShots") + ), + 0, + ) + away_shots = next( + ( + int(c["displayValue"]) + for c in away_stats + if c.get("name") in ("shots", "totalShots") + ), + 0, + ) + + # Format period/quarter. NCAA men's & women's lacrosse use 4 quarters. + period = status.get("period", 0) + period_text = "" + if status["type"]["state"] == "in": + if period == 0: + period_text = "Start" + elif 1 <= period <= 4: + period_text = f"Q{period}" + elif period > 4: + period_text = f"OT{period - 4}" + elif status["type"]["state"] == "post": + if period > 4: + period_text = "Final/OT" + else: + period_text = "Final" + elif status["type"]["state"] == "pre": + period_text = details.get("game_time", "") + + details.update( + { + "period": period, + "period_text": period_text, + "clock": status.get("displayClock", "0:00"), + "home_shots": home_shots, + "away_shots": away_shots, + } + ) + + if not details["home_abbr"] or not details["away_abbr"]: + self.logger.warning( + f"Missing team abbreviation in event: {details['id']}" + ) + return None + + self.logger.debug( + f"Extracted: {details['away_abbr']}@{details['home_abbr']}, " + f"Status: {status['type']['name']}, Live: {details['is_live']}, " + f"Final: {details['is_final']}, Upcoming: {details['is_upcoming']}" + ) + + 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 + + def _get_team_display_text(self, abbr: str, record: str) -> str: + """Pick the short text shown under a team logo. + + When `show_ranking` is enabled, the poll rank (if any) wins and the + record is hidden. When only `show_records` is enabled, the W-L record + is shown. Unranked teams get an empty string under ranking-only mode. + """ + if not abbr: + return "" + if self.show_ranking: + rank = self._team_rankings_cache.get(abbr, 0) + if rank > 0: + return f"#{rank}" + return "" + if self.show_records: + return record or "" + return "" + + +class LacrosseLive(Lacrosse, 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 not (self.current_game and self.current_game.get("is_live")): + return + # For testing, tick the clock down to show updates working. + clock_str = self.current_game.get("clock") or "" + try: + minutes_str, seconds_str = clock_str.split(":", 1) + minutes = int(minutes_str) + seconds = int(seconds_str) + except (ValueError, AttributeError): + # Malformed clock — reset to the start of a 15-minute quarter. + self.logger.debug( + f"Test clock reset: unparseable value {clock_str!r}" + ) + self.current_game["clock"] = "15:00" + return + seconds -= 1 + if seconds < 0: + seconds = 59 + minutes -= 1 + if minutes < 0: + minutes = 14 # 15-minute quarters (NCAA men's) + if self.current_game.get("period", 1) < 4: + self.current_game["period"] = self.current_game.get("period", 1) + 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 Lacrosse game.""" + 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) + 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 live game: {game.get('id')}" + ) + 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.paste(main_img.convert("RGB"), (0, 0)) + self.display_manager.update_display() + return + + center_y = self.display_height // 2 + + home_x = ( + self.display_width - home_logo.width + 10 + + 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 = -10 + 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) + + # Quarter 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", "Quarter 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, slightly above bottom) + 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"] + ) + + # Shot totals + if self.show_shots: + try: + shots_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + except (OSError, IOError): + shots_font = ImageFont.load_default() + home_shots = str(game.get("home_shots", "0")) + away_shots = str(game.get("away_shots", "0")) + shots_text = f"{away_shots} SHOTS {home_shots}" + shots_bbox = draw_overlay.textbbox((0, 0), shots_text, font=shots_font) + shots_height = shots_bbox[3] - shots_bbox[1] + shots_y = self.display_height - shots_height - 1 + shots_width = draw_overlay.textlength(shots_text, font=shots_font) + shots_x = (self.display_width - shots_width) // 2 + self._draw_text_with_outline( + draw_overlay, shots_text, (shots_x, shots_y), shots_font + ) + + # Draw odds if available + if game.get("odds"): + self._draw_dynamic_odds( + draw_overlay, game["odds"], self.display_width, self.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) + except IOError: + record_font = ImageFont.load_default() + + 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 - 1 + + away_text = self._get_team_display_text( + away_abbr, game.get("away_record", "") + ) + if away_text: + self._draw_text_with_outline( + draw_overlay, + away_text, + (3, record_y), + record_font, + ) + + home_text = self._get_team_display_text( + home_abbr, game.get("home_record", "") + ) + 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 - 3 + self._draw_text_with_outline( + draw_overlay, + home_text, + (home_record_x, record_y), + record_font, + ) + + # Composite text overlay onto main image + 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 live Lacrosse game: {e}", exc_info=True + ) diff --git a/plugins/lacrosse-scoreboard/logo_downloader.py b/plugins/lacrosse-scoreboard/logo_downloader.py new file mode 100644 index 00000000..8efe0ddb --- /dev/null +++ b/plugins/lacrosse-scoreboard/logo_downloader.py @@ -0,0 +1,142 @@ +""" +Simplified LogoDownloader for plugin use +""" + +import os +import logging +import requests +from typing import Dict, Any, List, Optional, Tuple +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +logger = logging.getLogger(__name__) + +class LogoDownloader: + """Simplified logo downloader for team logos from ESPN API.""" + + def __init__(self, request_timeout: int = 30, retry_attempts: int = 3): + """Initialize the logo downloader with HTTP session and retry logic.""" + self.request_timeout = request_timeout + self.retry_attempts = retry_attempts + + # Set up session with retry logic + self.session = requests.Session() + retry_strategy = Retry( + total=retry_attempts, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["GET", "HEAD", "OPTIONS"] + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("https://", adapter) + self.session.mount("http://", adapter) + + # Set up headers + self.headers = { + 'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)', + 'Accept': 'application/json', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive' + } + + @staticmethod + def normalize_abbreviation(abbr: str) -> str: + """Normalize team abbreviation for filename.""" + return abbr.upper() + + @staticmethod + def get_logo_filename_variations(abbr: str) -> List[str]: + """Get possible filename variations for a team abbreviation.""" + normalized = LogoDownloader.normalize_abbreviation(abbr) + variations = [f"{normalized}.png"] + + # Add common variations + if normalized == "TA&M": + variations.append("TAANDM.png") + elif normalized == "TAMU": + variations.append("TA&M.png") + + return variations + +def download_missing_logo(sport_key: str, team_id: str, team_abbr: str, logo_path: Path, logo_url: Optional[str] = None) -> bool: + """ + Download missing logo for a team. + + A placeholder logo is always written to disk as a fallback so the caller + can safely open the file afterwards. The return value reflects whether + the *real* logo was downloaded from the remote URL — callers that care + about distinguishing a real logo from a placeholder should inspect the + return value. + + Args: + sport_key: Sport key (e.g., 'nfl', 'ncaa_fb') + team_id: Team ID + team_abbr: Team abbreviation + logo_path: Path where logo should be saved + logo_url: Optional logo URL + + Returns: + True if the real logo was downloaded successfully from logo_url, + False if a placeholder was created as a fallback or the download failed. + """ + try: + # Create directory if it doesn't exist + logo_path.parent.mkdir(parents=True, exist_ok=True) + + # If we have a logo URL, try to download it + if logo_url: + response = requests.get(logo_url, timeout=30) + if response.status_code == 200: + with open(logo_path, 'wb') as f: + f.write(response.content) + logger.info(f"Downloaded logo for {team_abbr} from {logo_url}") + return True + + # If no URL or download failed, create a placeholder + create_placeholder_logo(team_abbr, logo_path) + return False + + except Exception as e: + logger.error(f"Failed to download logo for {team_abbr}: {e}") + # Create placeholder as fallback + create_placeholder_logo(team_abbr, logo_path) + return False + +def create_placeholder_logo(team_abbr: str, logo_path: Path) -> None: + """Create a simple placeholder logo.""" + try: + # Create a simple text-based logo + img = Image.new('RGBA', (64, 64), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Try to load a font, falling back to PIL's built-in default if the + # bundled font is missing or cannot be read. + try: + font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) + except (OSError, IOError) as e: + logger.debug(f"Placeholder font unavailable ({e}); using PIL default") + font = ImageFont.load_default() + + # Draw team abbreviation + text = team_abbr[:3] # Limit to 3 characters + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + x = (64 - text_width) // 2 + y = (64 - text_height) // 2 + + # Draw 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), text, font=font, fill=(0, 0, 0)) + draw.text((x, y), text, font=font, fill=(255, 255, 255)) + + # Save the placeholder + img.save(logo_path) + logger.info(f"Created placeholder logo for {team_abbr}") + + except Exception as e: + logger.error(f"Failed to create placeholder logo for {team_abbr}: {e}") diff --git a/plugins/lacrosse-scoreboard/manager.py b/plugins/lacrosse-scoreboard/manager.py new file mode 100644 index 00000000..bc646edc --- /dev/null +++ b/plugins/lacrosse-scoreboard/manager.py @@ -0,0 +1,2982 @@ +""" +Lacrosse Scoreboard Plugin for LEDMatrix - Using Existing Managers + +This plugin provides NCAA Men's and NCAA Women's lacrosse scoreboard functionality by reusing +the proven, working manager classes adapted from the lacrosse scoreboard plugin. +""" + +import logging +import time +from typing import Dict, Any, Optional, Set, List, Tuple + +try: + from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode +except ImportError: + BasePlugin = None + VegasDisplayMode = None + +# Import shared background service from LEDMatrix core +try: + from src.background_data_service import get_background_service +except ImportError: + get_background_service = None + +# Import scroll display components +try: + from scroll_display import ScrollDisplayManager + SCROLL_AVAILABLE = True +except ImportError: + ScrollDisplayManager = None + SCROLL_AVAILABLE = False + +# Import the copied manager classes. +# This plugin ships NCAA men's and women's lacrosse only — there is no +# pro lacrosse league in the scope of this plugin. +from ncaam_lacrosse_managers import ( + NCAAMLacrosseLiveManager, + NCAAMLacrosseRecentManager, + NCAAMLacrosseUpcomingManager, +) +from ncaaw_lacrosse_managers import ( + NCAAWLacrosseLiveManager, + NCAAWLacrosseRecentManager, + NCAAWLacrosseUpcomingManager, +) + +logger = logging.getLogger(__name__) + + +class LacrosseScoreboardPlugin(BasePlugin if BasePlugin else object): + """ + Lacrosse scoreboard plugin. + + Provides NCAA Men's and NCAA Women's lacrosse scoreboard functionality by + delegating to per-league manager classes, with support for live, recent, + and upcoming game modes, favorite-team filtering, poll-rank badges, and + per-mode switch/scroll display styles. + """ + + def __init__( + self, + plugin_id: str, + config: Dict[str, Any], + display_manager, + cache_manager, + plugin_manager, + ): + """Initialize the lacrosse 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) + # Get display dimensions from display_manager properties + 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) + + # League configurations (defaults come from schema via plugin_manager merge) + self.logger.debug(f"Lacrosse plugin received config keys: {list(config.keys())}") + + self.ncaa_mens_enabled = config.get("ncaa_mens", {}).get("enabled", False) + self.ncaa_womens_enabled = config.get("ncaa_womens", {}).get("enabled", False) + + self.logger.info( + f"League enabled states - NCAA Men's: {self.ncaa_mens_enabled}, " + f"NCAA Women's: {self.ncaa_womens_enabled}" + ) + + # Live priority settings + self.ncaa_mens_live_priority = self.config.get("ncaa_mens", {}).get( + "live_priority", False + ) + self.ncaa_womens_live_priority = self.config.get("ncaa_womens", {}).get( + "live_priority", False + ) + + # Global settings - read from defaults section with fallback + defaults = config.get("defaults", {}) + self.display_duration = float(defaults.get("display_duration", config.get("display_duration", 30))) + self.game_display_duration = float(defaults.get("display_duration", config.get("game_display_duration", 15))) + + # Additional settings - read from defaults section with fallback + self.show_records = defaults.get("show_records", config.get("show_records", False)) + self.show_ranking = defaults.get("show_ranking", config.get("show_ranking", False)) + self.show_odds = defaults.get("show_odds", config.get("show_odds", False)) + + # 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 + ) + except Exception as e: + self.logger.warning(f"Could not initialize background service: {e}") + + # 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 + else: + self.logger.debug("Scroll mode not available - ScrollDisplayManager not imported") + + # Track current scroll state + self._scroll_active: Dict[str, bool] = {} # {game_type: is_active} + self._scroll_prepared: Dict[str, bool] = {} # {game_type: is_prepared} + + # Enable high-FPS mode for scroll display (allows 100+ FPS scrolling) + # This signals to the display controller to use high-FPS loop (8ms = 125 FPS) + self.enable_scrolling = self._scroll_manager is not None + if self.enable_scrolling: + self.logger.info("High-FPS scrolling enabled for lacrosse scoreboard") + + # League registry: maps league IDs to their configuration and managers + # This structure makes it easy to add more leagues in the future + # Format: {league_id: {'enabled': bool, 'priority': int, 'live_priority': bool, 'managers': {...}}} + # The registry will be populated after managers are initialized + self._league_registry: Dict[str, Dict[str, Any]] = {} + + # Track current display context for granular dynamic duration + self._current_display_league: Optional[str] = None # 'ncaa_mens' or 'ncaa_womens' + self._current_display_mode_type: Optional[str] = None # 'live', 'recent', 'upcoming' + + # Initialize managers + self._initialize_managers() + + # Initialize league registry after managers are created + # This centralizes league management and makes it easy to add more leagues + self._initialize_league_registry() + + # Mode cycling (like football plugin) + self.current_mode_index = 0 + self.last_mode_switch = time.time() + self.modes = self._get_available_modes() + + # Dynamic duration tracking state + 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 + # Track when single-game managers were first seen to ensure full duration + self._single_game_manager_start_times: Dict[str, float] = {} + # Track when each game ID was first seen to ensure full per-game duration + # Using game IDs instead of indices prevents start time resets when game order changes + self._game_id_start_times: Dict[str, Dict[str, float]] = {} # {manager_key: {game_id: start_time}} + # Track which managers were actually used for each display mode + self._display_mode_to_managers: Dict[str, Set[str]] = {} # {display_mode: {manager_key, ...}} + + # Track last display mode to detect when we return after being away + self._last_display_mode: Optional[str] = None # Track previous display mode + self._last_display_mode_time: float = 0.0 # When we last saw this mode + self._current_active_display_mode: Optional[str] = None # Currently active external display mode + + # Throttle logging for has_live_content() when returning False + self._last_live_content_false_log: float = 0.0 # Timestamp of last False log + self._live_content_log_interval: float = 60.0 # Log False results every 60 seconds + + # Track current game for transition detection + # Format: {display_mode: {'game_id': str, 'league': str, 'last_log_time': float}} + self._current_game_tracking: Dict[str, Dict[str, Any]] = {} + self._game_transition_log_interval: float = 1.0 # Minimum seconds between game transition logs + + # Track mode start times for per-mode duration enforcement + # Format: {display_mode: start_time} (e.g., {'ncaa_mens_recent': 1234567890.0}) + # Reset when mode changes or full cycle completes + self._mode_start_time: Dict[str, float] = {} + + # Sticky manager tracking - ensures we complete all games from one league before switching + self._sticky_manager_per_mode: Dict[str, Any] = {} # {display_mode: manager_instance} + self._sticky_manager_start_time: Dict[str, float] = {} # {display_mode: timestamp} + + # Display mode settings parsing (for future scroll mode support in config schema) + self._display_mode_settings = self._parse_display_mode_settings() + + # Initialize scroll display manager if available + self._scroll_manager = None + if SCROLL_AVAILABLE and ScrollDisplayManager: + try: + self._scroll_manager = ScrollDisplayManager( + self.display_manager, + self.config, + self.logger + ) + self.logger.info("Lacrosse scroll display manager initialized") + except Exception as e: + self.logger.warning(f"Could not initialize scroll display manager: {e}") + else: + self.logger.info("Scroll display not available - scroll mode disabled") + + # Scroll state tracking + self._scroll_prepared = {} # Tracks which scroll modes are prepared + self._scroll_active = {} # Tracks which scroll modes are active + + self.logger.info( + f"Lacrosse scoreboard plugin initialized - {self.display_width}x{self.display_height}" + ) + self.logger.info( + f"NCAA Men's enabled: {self.ncaa_mens_enabled}, NCAA Women's enabled: {self.ncaa_womens_enabled}" + ) + + def _initialize_managers(self): + """Initialize all manager instances.""" + try: + # Create adapted configs for managers + ncaa_mens_config = self._adapt_config_for_manager("ncaa_mens") + ncaa_womens_config = self._adapt_config_for_manager("ncaa_womens") + + # Initialize NCAA Men's managers if enabled + if self.ncaa_mens_enabled: + try: + self.ncaa_mens_live = NCAAMLacrosseLiveManager( + ncaa_mens_config, self.display_manager, self.cache_manager + ) + self.ncaa_mens_recent = NCAAMLacrosseRecentManager( + ncaa_mens_config, self.display_manager, self.cache_manager + ) + self.ncaa_mens_upcoming = NCAAMLacrosseUpcomingManager( + ncaa_mens_config, self.display_manager, self.cache_manager + ) + self.logger.info("NCAA Men's Lacrosse managers initialized") + except Exception as e: + self.logger.error(f"Failed to initialize NCAA Men's Lacrosse managers: {e}", exc_info=True) + # Set to None so hasattr checks work correctly + if not hasattr(self, "ncaa_mens_live"): + self.ncaa_mens_live = None + if not hasattr(self, "ncaa_mens_recent"): + self.ncaa_mens_recent = None + if not hasattr(self, "ncaa_mens_upcoming"): + self.ncaa_mens_upcoming = None + + # Initialize NCAA Women's managers if enabled + if self.ncaa_womens_enabled: + try: + self.ncaa_womens_live = NCAAWLacrosseLiveManager( + ncaa_womens_config, self.display_manager, self.cache_manager + ) + self.ncaa_womens_recent = NCAAWLacrosseRecentManager( + ncaa_womens_config, self.display_manager, self.cache_manager + ) + self.ncaa_womens_upcoming = NCAAWLacrosseUpcomingManager( + ncaa_womens_config, self.display_manager, self.cache_manager + ) + self.logger.info("NCAA Women's Lacrosse managers initialized") + except Exception as e: + self.logger.error(f"Failed to initialize NCAA Women's Lacrosse managers: {e}", exc_info=True) + # Set to None so hasattr checks work correctly + if not hasattr(self, "ncaa_womens_live"): + self.ncaa_womens_live = None + if not hasattr(self, "ncaa_womens_recent"): + self.ncaa_womens_recent = None + if not hasattr(self, "ncaa_womens_upcoming"): + self.ncaa_womens_upcoming = None + + 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 all available leagues. + + The league registry centralizes league management and makes it easy to: + - Add new leagues in the future (just add an entry here) + - Query enabled leagues for a mode type + - Get managers in priority order + - Check league completion status + + Registry format: + { + 'league_id': { + 'enabled': bool, # Whether the league is enabled + 'priority': int, # Display priority (lower = higher priority) + 'live_priority': bool, # Whether live priority is enabled for this league + 'managers': { + 'live': Manager or None, + 'recent': Manager or None, + 'upcoming': Manager or None + } + } + } + + This design allows the display logic to iterate through leagues in priority + order without hardcoding league names throughout the codebase. + """ + # NCAA Men's Lacrosse league entry - highest priority (1) + self._league_registry['ncaa_mens'] = { + 'enabled': self.ncaa_mens_enabled, + 'priority': 1, # Highest priority - shows first + 'live_priority': self.ncaa_mens_live_priority, + 'managers': { + 'live': getattr(self, 'ncaa_mens_live', None), + 'recent': getattr(self, 'ncaa_mens_recent', None), + 'upcoming': getattr(self, 'ncaa_mens_upcoming', None), + } + } + + # NCAA Women's Lacrosse league entry - second priority (2) + self._league_registry['ncaa_womens'] = { + 'enabled': self.ncaa_womens_enabled, + 'priority': 2, # Second priority - shows after NCAA Men's + 'live_priority': self.ncaa_womens_live_priority, + 'managers': { + 'live': getattr(self, 'ncaa_womens_live', None), + 'recent': getattr(self, 'ncaa_womens_recent', None), + 'upcoming': getattr(self, 'ncaa_womens_upcoming', None), + } + } + + # Log registry state for debugging + 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) registered, " + f"{len(enabled_leagues)} enabled: {enabled_leagues}" + ) + + def _get_enabled_leagues_for_mode(self, mode_type: str) -> List[str]: + """ + Get list of enabled leagues for a specific mode type in priority order. + + This method respects both league-level and mode-level disabling: + - League must be enabled (league.enabled = True) + - Mode must be enabled for that league (league.display_modes.show_ = True) + + Args: + mode_type: Mode type ('live', 'recent', or 'upcoming') + + Returns: + List of league IDs in priority order (lower priority number = higher priority) + Example: ['ncaa_mens', 'ncaa_womens'] means NCAA Men's shows first, then NCAA Women's + + This is the core method for sequential block display - it determines + which leagues should be shown and in what order. + """ + enabled_leagues = [] + + # Iterate through all registered leagues + for league_id, league_data in self._league_registry.items(): + # Check if league is enabled + if not league_data.get('enabled', False): + continue + + # Check if this mode type is enabled for this league + # Get the league config to check display_modes settings + league_config = self.config.get(league_id, {}) + display_modes_config = league_config.get("display_modes", {}) + + # Check the appropriate flag based on mode type + mode_enabled = True # Default to enabled if not specified + if mode_type == 'live': + mode_enabled = display_modes_config.get("live", display_modes_config.get("show_live", True)) + elif mode_type == 'recent': + mode_enabled = display_modes_config.get("recent", display_modes_config.get("show_recent", True)) + elif mode_type == 'upcoming': + mode_enabled = display_modes_config.get("upcoming", display_modes_config.get("show_upcoming", True)) + + # Only include if mode is enabled for this league + if mode_enabled: + enabled_leagues.append(league_id) + + # Sort by priority (lower number = higher priority) + enabled_leagues.sort(key=lambda lid: self._league_registry[lid].get('priority', 999)) + + self.logger.debug( + f"Enabled leagues for {mode_type} mode: {enabled_leagues} " + f"(priorities: {[self._league_registry[lid].get('priority') for lid in enabled_leagues]})" + ) + + return enabled_leagues + + def _get_managers_for_mode_type(self, mode_type: str) -> List: + """ + Get managers in priority order for a specific mode type. + + This method returns manager instances for all enabled leagues that have + the specified mode type enabled, sorted by league priority. + + Args: + mode_type: Mode type ('live', 'recent', or 'upcoming') + + Returns: + List of manager instances in priority order (highest priority first) + Managers are filtered to only include enabled leagues with the mode enabled + + This is used by the sequential block display logic to determine which + leagues should be shown and in what order. + """ + managers = [] + + # Get enabled leagues for this mode type in priority order + enabled_leagues = self._get_enabled_leagues_for_mode(mode_type) + + # Get managers for each enabled league in priority order + for league_id in enabled_leagues: + manager = self._get_league_manager_for_mode(league_id, mode_type) + if manager: + managers.append(manager) + self.logger.debug( + f"Added {league_id} {mode_type} manager to priority list " + f"(priority: {self._league_registry[league_id].get('priority', 999)})" + ) + + self.logger.debug( + f"Managers in priority order for {mode_type}: " + f"{[m.__class__.__name__ for m in managers]}" + ) + + return managers + + def _get_league_manager_for_mode(self, league_id: str, mode_type: str): + """ + Get the manager instance for a specific league and mode type. + + This is a convenience method that looks up managers from the league registry. + It provides a single point of access for getting managers, making the code + more maintainable and easier to extend. + + Args: + league_id: League identifier ('ncaa_mens' or 'ncaa_womens') + mode_type: Mode type ('live', 'recent', or 'upcoming') + + Returns: + Manager instance if found, None otherwise + + The manager is retrieved from the league registry, which is populated + during initialization. If the league or mode doesn't exist, returns None. + """ + # Check if league exists in registry + if league_id not in self._league_registry: + self.logger.warning(f"League {league_id} not found in registry") + return None + + # Get managers dict for this league + managers = self._league_registry[league_id].get('managers', {}) + + # Get the manager for this mode type + manager = managers.get(mode_type) + + if manager is None: + self.logger.debug(f"No manager found for {league_id} {mode_type}") + + return manager + + def _is_league_complete_for_mode(self, league_id: str, mode_type: str) -> bool: + """ + Check if a league has completed showing all games for a specific mode type. + + This is used in sequential block display to determine when to move from + one league to the next. A league is considered complete when all its games + have been shown for their full duration (tracked via dynamic duration system). + + Args: + league_id: League identifier ('ncaa_mens' or 'ncaa_womens') + mode_type: Mode type ('live', 'recent', or 'upcoming') + + Returns: + True if the league's manager for this mode is marked as complete, + False otherwise + + The completion status is tracked in _dynamic_managers_completed set, + using manager keys in the format: "{league_id}_{mode_type}:ManagerClass" + """ + # Get the manager for this league and mode + manager = self._get_league_manager_for_mode(league_id, mode_type) + if not manager: + # No manager means league can't be displayed, so consider it "complete" + # (nothing to show, so we can move on) + return True + + # Build the manager key that matches what's used in progress tracking + # Format: "{league_id}_{mode_type}:ManagerClass" + manager_key = self._build_manager_key(f"{league_id}_{mode_type}", manager) + + # Check if this manager is in the completed set + is_complete = manager_key in self._dynamic_managers_completed + + if is_complete: + self.logger.debug(f"League {league_id} {mode_type} is complete (manager_key: {manager_key})") + else: + self.logger.debug(f"League {league_id} {mode_type} is not complete (manager_key: {manager_key})") + + return is_complete + + def _get_default_logo_dir(self, league: str) -> str: + """ + Get the default logo directory for a league. + Matches the directories used in src/logo_downloader.py. + """ + # Map leagues to their logo directories (matching logo_downloader.py) + logo_dir_map = { + 'ncaa_mens': 'assets/sports/ncaa_logos', # NCAA Men's Lacrosse uses ncaa_logos + 'ncaa_womens': 'assets/sports/ncaa_logos', # NCAA Women's Lacrosse uses ncaa_logos + } + # Default to league-specific directory if not in map + return logo_dir_map.get(league, f"assets/sports/{league}_logos") + + def _parse_display_mode_settings(self) -> Dict[str, Dict[str, str]]: + """ + Parse display mode settings from config. + + Returns: + Dict mapping league -> game_type -> display_mode ('switch' or 'scroll') + e.g., {'ncaa_mens': {'live': 'switch', 'recent': 'switch', 'upcoming': 'switch'}} + """ + settings = {} + + for league in ['ncaa_mens', 'ncaa_womens']: + league_config = self.config.get(league, {}) + display_modes_config = league_config.get("display_modes", {}) + + settings[league] = { + '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 {league}: {settings[league]}") + + return settings + + def _get_display_mode(self, league: str, game_type: str) -> str: + """ + Get the display mode for a specific league and game type. + + Args: + league: 'ncaa_mens' or 'ncaa_womens' + game_type: 'live', 'recent', or 'upcoming' + + Returns: + 'switch' or 'scroll' + """ + if league not in self._display_mode_settings: + return 'switch' + + return self._display_mode_settings[league].get(game_type, 'switch') + + def _extract_mode_type(self, display_mode: str) -> Optional[str]: + """Extract mode type (live, recent, upcoming) from display mode string. + + Args: + display_mode: Display mode string (e.g., 'ncaa_mens_live', 'ncaa_womens_recent') + + Returns: + Mode type string ('live', 'recent', 'upcoming') or None + """ + if display_mode.endswith('_live'): + return 'live' + elif display_mode.endswith('_recent'): + return 'recent' + elif display_mode.endswith('_upcoming'): + return 'upcoming' + return None + + def _get_game_duration(self, league: str, mode_type: str, manager=None) -> float: + """Get game duration for a league and mode type combination. + + Resolves duration using the following hierarchy: + 1. Manager's game_display_duration attribute (if manager provided) + 2. League-specific mode duration (e.g., ncaa_mens.live_game_duration from display_durations.live) + 3. League-specific default (15 seconds) + + Args: + league: League name ('ncaa_mens' or 'ncaa_womens') + mode_type: Mode type ('live', 'recent', or 'upcoming') + manager: Optional manager instance (if provided, checks manager's game_display_duration) + + Returns: + Game duration in seconds (float) + """ + # First, try manager's game_display_duration if available + if manager: + manager_duration = getattr(manager, 'game_display_duration', None) + if manager_duration is not None: + return float(manager_duration) + + # Next, try league-specific mode duration from display_durations + league_config = self.config.get(league, {}) + display_durations = league_config.get("display_durations", {}) + mode_duration_key = mode_type # e.g., 'live' maps to display_durations.live + mode_duration = display_durations.get(mode_duration_key) + if mode_duration is not None: + return float(mode_duration) + + # Fallback to league-specific default (15 seconds) + return 15.0 + + def _get_mode_duration(self, league: str, mode_type: str) -> Optional[float]: + """ + Get mode duration from config for a league/mode combination. + + Checks per-league/per-mode settings first, then falls back to per-league settings. + Returns None if not configured (uses dynamic calculation). + + Args: + league: League name ('ncaa_mens' or 'ncaa_womens') + mode_type: Mode type ('live', 'recent', or 'upcoming') + + Returns: + Mode duration in seconds (float) or None if not configured + """ + league_config = self.config.get(league, {}) + mode_durations = league_config.get("mode_durations", {}) + + # Check per-mode setting (e.g., live_mode_duration, recent_mode_duration) + mode_duration_key = f"{mode_type}_mode_duration" + if mode_duration_key in mode_durations: + value = mode_durations[mode_duration_key] + if value is not None: + try: + return float(value) + except (TypeError, ValueError): + pass + + # No per-mode setting configured - return None to use dynamic calculation + return None + + 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 _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: + """ + Adapt plugin config format to manager expected format. + + Plugin uses: ncaa_mens: {...}, ncaa_womens: {...} + Managers expect: ncaam_lacrosse_scoreboard: {...}, ncaaw_lacrosse_scoreboard: {...}, etc. + + Supports both new nested structure and old flat structure for backward compatibility. + """ + league_config = self.config.get(league, {}) + defaults = self.config.get("defaults", {}) + + # Map league names to sport_key format expected by managers + sport_key_map = { + "ncaa_mens": "ncaam_lacrosse", + "ncaa_womens": "ncaaw_lacrosse", + } + sport_key = sport_key_map.get(league, league) + + # Extract nested configurations (new structure) with fallback to flat structure (old) + display_modes = league_config.get("display_modes", {}) + teams_config = league_config.get("teams", {}) + filtering_config = league_config.get("filtering", {}) + update_intervals = league_config.get("update_intervals", {}) + display_durations = league_config.get("display_durations", {}) + display_options = league_config.get("display_options", {}) + + def resolve_mode_flag(*keys: str, default: bool = True) -> bool: + for key in keys: + if key in display_modes: + return bool(display_modes[key]) + return default + + live_flag = resolve_mode_flag("live", "show_live") + recent_flag = resolve_mode_flag("recent", "show_recent") + upcoming_flag = resolve_mode_flag("upcoming", "show_upcoming") + + def resolve_value(nested_path: list, flat_keys: list, default): + """Resolve value from nested structure or fallback to flat structure.""" + # Try nested structure first + current = league_config + for key in nested_path: + if isinstance(current, dict) and key in current: + current = current[key] + else: + current = None + break + if current is not None: + return current + + # Try flat structure (backward compatibility) + for key in flat_keys: + if key in league_config: + return league_config[key] + + # Try defaults + if nested_path: + current = defaults + for key in nested_path: + if isinstance(current, dict) and key in current: + current = current[key] + else: + return default + return current + + return default + + # Resolve team settings + favorite_teams = resolve_value(["teams", "favorite_teams"], ["favorite_teams"], []) + favorite_only = resolve_value(["teams", "favorite_teams_only"], ["favorite_teams_only"], False) + show_all_live = resolve_value(["teams", "show_all_live"], ["show_all_live"], False) + + # Resolve filtering settings + recent_games_to_show = resolve_value(["filtering", "recent_games_to_show"], ["recent_games_to_show"], 5) + upcoming_games_to_show = resolve_value(["filtering", "upcoming_games_to_show"], ["upcoming_games_to_show"], 10) + + # Resolve update intervals + update_interval_seconds = resolve_value(["update_intervals", "base"], ["update_interval_seconds"], 60) + live_update_interval = resolve_value(["update_intervals", "live"], ["live_update_interval"], 15) + recent_update_interval = resolve_value(["update_intervals", "recent"], ["recent_update_interval"], 3600) + upcoming_update_interval = resolve_value(["update_intervals", "upcoming"], ["upcoming_update_interval"], 3600) + + # Resolve display durations + def resolve_live_duration() -> int: + # Try new nested structure + if "display_durations" in league_config and "live" in league_config["display_durations"]: + return int(league_config["display_durations"]["live"]) + # Try old flat structure + if "live_game_duration" in league_config: + return int(league_config["live_game_duration"]) + if "game_rotation_interval_seconds" in league_config: + return int(league_config["game_rotation_interval_seconds"]) + if "live_display_duration" in league_config: + return int(league_config["live_display_duration"]) + return 20 + + # Resolve display options with defaults fallback + show_records = resolve_value(["display_options", "show_records"], ["show_records"], self.show_records) + show_ranking = resolve_value(["display_options", "show_ranking"], ["show_ranking"], self.show_ranking) + show_odds = resolve_value(["display_options", "show_odds"], ["show_odds"], self.show_odds) + show_shots = resolve_value(["display_options", "show_shots"], ["show_shots"], False) + + # Create manager config with expected structure + manager_config = { + f"{sport_key}_scoreboard": { + "enabled": league_config.get("enabled", False), + "favorite_teams": favorite_teams, + "display_modes": { + "live": live_flag, + "recent": recent_flag, + "upcoming": upcoming_flag, + }, + "recent_games_to_show": recent_games_to_show, + "upcoming_games_to_show": upcoming_games_to_show, + "show_records": show_records, + "show_ranking": show_ranking, + "show_odds": show_odds, + "show_shots": show_shots, + "show_favorite_teams_only": favorite_only, + "show_all_live": show_all_live, + "live_priority": league_config.get("live_priority", False), + "update_interval_seconds": update_interval_seconds, + "live_update_interval": live_update_interval, + "recent_update_interval": recent_update_interval, + "upcoming_update_interval": upcoming_update_interval, + "live_game_duration": resolve_live_duration(), + "background_service": { + "request_timeout": 30, + "max_retries": 3, + "priority": 2, + }, + } + } + + # Add global config - get timezone from cache_manager's config_manager if available + 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" + + # Get display config from main config if available + 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() + + # Get customization config from main config (shared across all leagues) + customization_config = self.config.get("customization", {}) + + manager_config.update( + { + "timezone": timezone_str, + "display": display_config, + "customization": customization_config, + } + ) + + return manager_config + + def _get_available_modes(self) -> list: + """Get list of available display modes based on enabled leagues using league registry.""" + modes = [] + + # Use league registry to build mode list in priority order + # Iterate through leagues in priority order (lower priority number = higher priority) + sorted_leagues = sorted( + self._league_registry.items(), + key=lambda item: item[1].get('priority', 999) + ) + + for league_id, league_data in sorted_leagues: + # Check if league is enabled + if not league_data.get('enabled', False): + continue + + # Get league config to check display_modes settings + league_config = self.config.get(league_id, {}) + display_modes_config = league_config.get("display_modes", {}) + + # Check each mode type + for mode_type in ['recent', 'upcoming', 'live']: # Order: recent, upcoming, live + mode_enabled = True # Default to enabled if not specified + if mode_type == 'live': + mode_enabled = display_modes_config.get("live", display_modes_config.get("show_live", True)) + elif mode_type == 'recent': + mode_enabled = display_modes_config.get("recent", display_modes_config.get("show_recent", True)) + elif mode_type == 'upcoming': + mode_enabled = display_modes_config.get("upcoming", display_modes_config.get("show_upcoming", True)) + + if mode_enabled: + modes.append(f"{league_id}_{mode_type}") + + # Default to NCAA Men's Lacrosse if no leagues enabled + if not modes: + modes = ["ncaa_mens_recent", "ncaa_mens_upcoming", "ncaa_mens_live"] + + return modes + + def _get_current_manager(self): + """Get the current manager based on the current mode (like football plugin).""" + if not self.modes: + return None + + current_mode = self.modes[self.current_mode_index] + + if current_mode.startswith("ncaa_mens_"): + if not self.ncaa_mens_enabled: + return None + mode_type = current_mode.split("_", 2)[2] # "live", "recent", "upcoming" + if mode_type == "live": + return self.ncaa_mens_live + elif mode_type == "recent": + return self.ncaa_mens_recent + elif mode_type == "upcoming": + return self.ncaa_mens_upcoming + + elif current_mode.startswith("ncaa_womens_"): + if not self.ncaa_womens_enabled: + return None + mode_type = current_mode.split("_", 2)[2] # "live", "recent", "upcoming" + if mode_type == "live": + return self.ncaa_womens_live + elif mode_type == "recent": + return self.ncaa_womens_recent + elif mode_type == "upcoming": + return self.ncaa_womens_upcoming + + 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}") + + def update(self) -> None: + """Update lacrosse game data.""" + if not self.is_enabled: + return + + current_time = time.time() + # Log plugin update calls for debugging (every 5 minutes) + if not hasattr(self, '_last_plugin_update_log') or current_time - self._last_plugin_update_log >= 300: + self.logger.info(f"Plugin update() called at {current_time}") + self._last_plugin_update_log = current_time + + try: + # Update NCAA Men's managers if enabled + if self.ncaa_mens_enabled: + for attr in ("ncaa_mens_live", "ncaa_mens_recent", "ncaa_mens_upcoming"): + manager = getattr(self, attr, None) + if manager: + manager.update() + + # Update NCAA Women's managers if enabled + if self.ncaa_womens_enabled: + for attr in ( + "ncaa_womens_live", + "ncaa_womens_recent", + "ncaa_womens_upcoming", + ): + manager = getattr(self, attr, None) + if manager: + manager.update() + + except Exception as e: + self.logger.error(f"Error updating managers: {e}", exc_info=True) + + def display(self, display_mode: str = None, force_clear: bool = False) -> bool: + """Display lacrosse games for a specific granular mode. + + The plugin now uses granular modes directly (ncaa_mens_recent, ncaa_womens_live, + ncaa_mens_recent, ncaa_mens_upcoming, ncaa_mens_live, etc.) registered in manifest.json. + The display controller handles rotation between these modes. + + Args: + display_mode: Granular mode name (e.g., 'ncaa_mens_recent', 'ncaa_womens_upcoming', 'ncaa_mens_live') + Format: {league}_{mode_type} + If None, uses internal mode cycling (legacy support). + force_clear: If True, clear display before rendering + """ + if not self.is_enabled: + return False + + try: + # Track the current active display mode for use in is_cycle_complete() + if display_mode: + self._current_active_display_mode = display_mode + + # Route to appropriate display handler + if display_mode: + # Handle legacy combined modes (lacrosse_live, lacrosse_recent, lacrosse_upcoming) + # These should not be called with new architecture, but handle gracefully + # for backward compatibility during transition + if display_mode.startswith("lacrosse_"): + # Legacy combined mode - extract mode_type and show all enabled leagues + mode_type_str = display_mode.replace("lacrosse_", "") + if mode_type_str not in ['live', 'recent', 'upcoming']: + self.logger.warning( + f"Invalid legacy combined mode: {display_mode}" + ) + return False + + # Show all enabled leagues for this mode type (sequential block) + # This maintains backward compatibility during transition + enabled_leagues = self._get_enabled_leagues_for_mode(mode_type_str) + if not enabled_leagues: + self.logger.debug( + f"No enabled leagues for legacy mode {display_mode}" + ) + return False + + # Try to display from first enabled league (simplified fallback) + # Sequential block display would show all leagues, but for legacy + # mode support we just try the first one + for league_id in enabled_leagues: + success = self._display_league_mode(league_id, mode_type_str, force_clear) + if success: + return True + + # No content from any league + return False + + # Parse granular mode name: {league}_{mode_type} + # e.g., "ncaa_mens_recent" -> league="ncaa_mens", mode_type="recent" + # e.g., "ncaa_mens_recent" -> league="ncaa_mens", mode_type="recent" + # + # Scalable approach: Check league registry first, then extract mode type + # This works for any league naming convention (underscores, dots, etc.) + mode_type_str = None + league = None + + # Known mode type suffixes (standardized across all sports plugins) + mode_suffixes = ['_live', '_recent', '_upcoming'] + + # Try to match against league registry first (most reliable) + # Check each league ID in registry to see if display_mode starts with it + for league_id in self._league_registry.keys(): + for mode_suffix in mode_suffixes: + expected_mode = f"{league_id}{mode_suffix}" + if display_mode == expected_mode: + league = league_id + mode_type_str = mode_suffix[1:] # Remove leading underscore + break + if league: + break + + # Fallback: If no registry match, parse from the end (for backward compatibility) + if not league: + for mode_suffix in mode_suffixes: + if display_mode.endswith(mode_suffix): + mode_type_str = mode_suffix[1:] # Remove leading underscore + league = display_mode[:-len(mode_suffix)] # Everything before the suffix + # Validate it's a known league + if league in self._league_registry: + break + else: + # Not a known league, try next suffix + league = None + mode_type_str = None + + if not mode_type_str or not league: + self.logger.warning( + f"Invalid granular display_mode format: {display_mode} " + f"(expected format: {{league}}_{{mode_type}}, e.g., 'ncaa_mens_recent' or 'ncaa_womens_recent'). " + f"Valid leagues: {list(self._league_registry.keys())}" + ) + return False + + # Validate league exists in registry (double-check) + if league not in self._league_registry: + self.logger.warning( + f"Invalid league in display_mode: {league} (mode: {display_mode}). " + f"Valid leagues: {list(self._league_registry.keys())}" + ) + return False + + # Check if league is enabled + if not self._league_registry[league].get('enabled', False): + self.logger.debug( + f"League {league} is disabled, skipping {display_mode}" + ) + return False + + # Check if mode is enabled for this league + 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("live", display_modes_config.get("show_live", True)) + elif mode_type_str == 'recent': + mode_enabled = display_modes_config.get("recent", display_modes_config.get("show_recent", True)) + elif mode_type_str == 'upcoming': + mode_enabled = display_modes_config.get("upcoming", display_modes_config.get("show_upcoming", True)) + + if not mode_enabled: + self.logger.debug( + f"Mode {mode_type_str} is disabled for league {league}, skipping {display_mode}" + ) + return False + + # Display this specific league/mode combination + return self._display_league_mode(league, mode_type_str, force_clear) + else: + # No display_mode provided - use internal cycling (legacy support) + return self._display_internal_cycling(force_clear) + + except Exception as e: + self.logger.error(f"Error in display method: {e}", exc_info=True) + return False + + 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 + + # Pass the current active display mode to evaluate completion for the right mode + self._evaluate_dynamic_cycle_completion(display_mode=self._current_active_display_mode) + self.logger.info(f"is_cycle_complete() called: display_mode={self._current_active_display_mode}, returning {self._dynamic_cycle_complete}") + return self._dynamic_cycle_complete + + def _set_display_context_from_manager(self, manager, mode_type: str) -> None: + """Set current display league and mode type based on manager instance. + + Args: + manager: Manager instance + mode_type: 'live', 'recent', or 'upcoming' + """ + self._current_display_mode_type = mode_type + + # Check NCAA Men's managers + if manager in (getattr(self, 'ncaa_mens_live', None), + getattr(self, 'ncaa_mens_recent', None), + getattr(self, 'ncaa_mens_upcoming', None)): + self._current_display_league = 'ncaa_mens' + # Check NCAA Women's managers + elif manager in (getattr(self, 'ncaa_womens_live', None), + getattr(self, 'ncaa_womens_recent', None), + getattr(self, 'ncaa_womens_upcoming', None)): + self._current_display_league = 'ncaa_womens' + + @staticmethod + def _build_manager_key(mode_name: str, manager) -> str: + """Build a unique key for tracking a manager instance. + + Args: + mode_name: Display mode name (e.g., 'ncaa_mens_recent') + manager: Manager instance + + Returns: + Manager key string (e.g., 'ncaa_mens_recent:NCAAMLacrosseLiveManager') + """ + manager_name = manager.__class__.__name__ if manager else "None" + return f"{mode_name}:{manager_name}" + + @staticmethod + def _get_total_games_for_manager(manager) -> int: + """Get total number of games for a manager. + + Args: + manager: Manager instance + + Returns: + Number of games (0 if no games or manager is None) + """ + 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. + + Args: + manager: Manager instance + + Returns: + Set of game ID strings + """ + 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') + if game_id: + game_ids.add(str(game_id)) + else: + # Fallback to index-based identifier if ID missing + away_abbr = game.get('away_abbr', '') + home_abbr = game.get('home_abbr', '') + if away_abbr and home_abbr: + game_ids.add(f"{away_abbr}@{home_abbr}-{i}") + else: + game_ids.add(f"index-{i}") + break + return game_ids + + def _get_rankings_cache(self) -> Dict[str, int]: + """Get combined team rankings cache from all managers. + + Returns: + Dictionary mapping team abbreviations to their rankings/positions + Format: {'TB': 1, 'BOS': 2, ...} + Empty dict if no rankings available + """ + rankings = {} + + # Try to get rankings from each manager + for manager_attr in ['ncaa_mens_live', 'ncaa_mens_recent', 'ncaa_mens_upcoming', + 'ncaa_womens_live', 'ncaa_womens_recent', 'ncaa_womens_upcoming']: + manager = getattr(self, manager_attr, None) + if manager: + manager_rankings = getattr(manager, '_team_rankings_cache', {}) + if manager_rankings: + rankings.update(manager_rankings) + + return rankings + + def _get_manager_for_league_mode(self, league: str, mode_type: str): + """Get manager instance for a league and mode type combination. + + This is a convenience method that calls _get_league_manager_for_mode() + for consistency with football-scoreboard naming. + + Args: + league: 'ncaa_mens' or 'ncaa_womens' + mode_type: 'live', 'recent', or 'upcoming' + + Returns: + Manager instance or None if not available/enabled + """ + return self._get_league_manager_for_mode(league, mode_type) + + def _get_games_from_manager(self, manager, mode_type: str) -> List[Dict]: + """Get games list from a manager based on mode type. + + Args: + manager: Manager instance + mode_type: 'live', 'recent', or 'upcoming' + + Returns: + List of game dictionaries + """ + if mode_type == 'live': + return list(getattr(manager, 'live_games', []) or []) + elif mode_type == 'recent': + # Try games_list first (used by recent managers), then recent_games + games = getattr(manager, 'games_list', None) + if games is None: + games = getattr(manager, 'recent_games', []) + return list(games or []) + elif mode_type == 'upcoming': + # Try games_list first (used by upcoming managers), then upcoming_games + games = getattr(manager, 'games_list', None) + if games is None: + games = getattr(manager, 'upcoming_games', []) + return list(games or []) + return [] + + def _has_live_games_for_manager(self, manager) -> bool: + """Check if a manager has valid live games (for favorite teams if configured). + + Args: + manager: Manager instance to check + + Returns: + True if manager has live games that should be displayed + """ + if not manager: + return False + + live_games = getattr(manager, 'live_games', []) + if not live_games: + return False + + # Filter out games that are final or appear over + live_games = [g for g in live_games if not g.get('is_final', False)] + if hasattr(manager, '_is_game_really_over'): + live_games = [g for g in live_games if not manager._is_game_really_over(g)] + + if not live_games: + return False + + # If favorite teams are configured, only return True if there are live games for favorite teams + favorite_teams = getattr(manager, 'favorite_teams', []) + if favorite_teams: + has_favorite_live = any( + game.get('home_abbr') in favorite_teams + or game.get('away_abbr') in favorite_teams + for game in live_games + ) + return has_favorite_live + + # No favorite teams configured, any live game counts + return True + + def _filter_managers_by_live_content(self, managers: list, mode_type: str) -> list: + """Filter managers based on live content when in live mode. + + Args: + managers: List of manager instances + mode_type: 'live', 'recent', or 'upcoming' + + Returns: + Filtered list of managers with live content (for live mode) or original list + """ + if mode_type != 'live': + return managers + + # For live mode, only include managers with actual live games + filtered = [] + for manager in managers: + if self._has_live_games_for_manager(manager): + filtered.append(manager) + + return filtered + + def _apply_sticky_manager_logic(self, display_mode: str, managers_to_try: list) -> list: + """Apply sticky manager logic to filter managers list. + + Args: + display_mode: External display mode name + managers_to_try: List of managers to try + + Returns: + Filtered list of managers (only sticky manager if exists and available) + """ + sticky_manager = self._sticky_manager_per_mode.get(display_mode) + + self.logger.info( + f"Sticky manager check for {display_mode}: " + f"sticky={sticky_manager.__class__.__name__ if sticky_manager else None}, " + f"available_managers={[m.__class__.__name__ for m in managers_to_try if m]}" + ) + + if sticky_manager and sticky_manager in managers_to_try: + self.logger.info( + f"Using sticky manager {sticky_manager.__class__.__name__} for {display_mode} - " + "RESTRICTING to this manager only" + ) + return [sticky_manager] + + # No sticky manager or not in list - clean up if needed + if sticky_manager: + self.logger.info( + f"Sticky manager {sticky_manager.__class__.__name__} no longer available for {display_mode}, " + f"selecting new one from {len(managers_to_try)} options" + ) + self._sticky_manager_per_mode.pop(display_mode, None) + self._sticky_manager_start_time.pop(display_mode, None) + else: + self.logger.info( + f"No sticky manager yet for {display_mode}, will select from {len(managers_to_try)} available managers" + ) + + return managers_to_try + + def _resolve_managers_for_mode(self, mode_type: str) -> list: + """ + Resolve ordered list of managers to try for a given mode type. + + This method uses the league registry to get managers in priority order, + respecting both league-level and mode-level enabling/disabling. + + For live mode, it also respects live_priority settings and filters + to only include managers with actual live games. + + Args: + mode_type: 'live', 'recent', or 'upcoming' + + Returns: + Ordered list of manager instances to try (in priority order) + Managers are filtered based on: + - League enabled state + - Mode enabled state for that league (live, recent, upcoming) + - For live mode: live_priority and actual live games availability + """ + managers_to_try = [] + + # Get enabled leagues for this mode type in priority order + # This already respects league-level and mode-level enabling + enabled_leagues = self._get_enabled_leagues_for_mode(mode_type) + + if mode_type == 'live': + # For live mode, update managers first to get current live games + # This ensures we have fresh data before checking for live content + for league_id in enabled_leagues: + manager = self._get_league_manager_for_mode(league_id, 'live') + if manager: + try: + manager.update() + except Exception as e: + self.logger.debug(f"Error updating {league_id} live manager: {e}") + + # For live mode, respect live_priority settings + # Only include managers with live_priority enabled AND actual live games + for league_id in enabled_leagues: + league_data = self._league_registry.get(league_id, {}) + live_priority = league_data.get('live_priority', False) + + manager = self._get_league_manager_for_mode(league_id, 'live') + if not manager: + continue + + # If live_priority is enabled, only include if manager has live games + if live_priority: + if self._has_live_games_for_manager(manager): + managers_to_try.append(manager) + self.logger.debug( + f"{league_id} has live games and live_priority - adding to list" + ) + else: + # No live_priority - include manager anyway (fallback) + managers_to_try.append(manager) + self.logger.debug( + f"{league_id} live manager added (no live_priority requirement)" + ) + + # If no managers found with live_priority, fall back to all enabled managers + # This ensures we always have something to show if leagues are enabled + if not managers_to_try: + for league_id in enabled_leagues: + manager = self._get_league_manager_for_mode(league_id, 'live') + if manager: + managers_to_try.append(manager) + self.logger.debug( + f"Fallback: added {league_id} live manager (no live_priority managers found)" + ) + else: + # For recent and upcoming modes, use standard priority order + # Get managers for each enabled league in priority order + for league_id in enabled_leagues: + manager = self._get_league_manager_for_mode(league_id, mode_type) + if manager: + managers_to_try.append(manager) + self.logger.debug( + f"Added {league_id} {mode_type} manager to list " + f"(priority: {self._league_registry[league_id].get('priority', 999)})" + ) + + self.logger.debug( + f"Resolved {len(managers_to_try)} manager(s) for {mode_type} mode: " + f"{[m.__class__.__name__ for m in managers_to_try]}" + ) + + return managers_to_try + + def _get_manager_for_mode(self, mode_name: str): + """Resolve manager instance for a given display mode. + + Args: + mode_name: Display mode name (e.g., 'ncaa_mens_recent', 'ncaa_womens_live') + + Returns: + Manager instance or None if not found/disabled + """ + if mode_name.startswith("ncaa_mens_"): + if not self.ncaa_mens_enabled: + return None + suffix = mode_name[len("ncaa_mens_"):] + if suffix == "live": + return getattr(self, "ncaa_mens_live", None) + if suffix == "recent": + return getattr(self, "ncaa_mens_recent", None) + if suffix == "upcoming": + return getattr(self, "ncaa_mens_upcoming", None) + elif mode_name.startswith("ncaa_womens_"): + if not self.ncaa_womens_enabled: + return None + suffix = mode_name[len("ncaa_womens_"):] + if suffix == "live": + return getattr(self, "ncaa_womens_live", None) + if suffix == "recent": + return getattr(self, "ncaa_womens_recent", None) + if suffix == "upcoming": + return getattr(self, "ncaa_womens_upcoming", None) + return None + + 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 (or no games). + + Args: + manager_key: Unique key identifying this manager + manager: Manager instance + league: League name ('ncaa_mens' or 'ncaa_womens') + mode_type: Mode type ('live', 'recent', or 'upcoming') + """ + current_time = time.time() + + if manager_key not in self._single_game_manager_start_times: + # First time seeing this single-game manager (in this cycle) - record start time + self._single_game_manager_start_times[manager_key] = current_time + game_duration = self._get_game_duration(league, mode_type, manager) if league and mode_type else getattr(manager, 'game_display_duration', 15) + self.logger.info(f"Single-game manager {manager_key} first seen at {current_time:.2f}, will complete after {game_duration}s") + else: + # Check if enough time has passed + start_time = self._single_game_manager_start_times[manager_key] + game_duration = self._get_game_duration(league, mode_type, manager) if league and mode_type else getattr(manager, 'game_display_duration', 15) + elapsed = current_time - start_time + if elapsed >= game_duration: + # Enough time has passed - mark as complete + 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 after {elapsed:.2f}s (required: {game_duration}s)") + # Clean up start time now that manager has completed + if manager_key in self._single_game_manager_start_times: + del self._single_game_manager_start_times[manager_key] + else: + # Still waiting + self.logger.debug(f"Single-game manager {manager_key} waiting: {elapsed:.2f}s/{game_duration}s (start_time={start_time:.2f}, current_time={current_time:.2f})") + + 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 + + # Use actual_mode if provided (when display_mode is specified), otherwise use internal mode cycling + if actual_mode: + current_mode = actual_mode + else: + current_mode = self.modes[self.current_mode_index] if self.modes else None + if current_mode is None: + return + + # Track both the internal mode and the external display mode if provided + self._dynamic_cycle_seen_modes.add(current_mode) + if display_mode and display_mode != current_mode: + # Also track the external display mode for proper completion checking + self._dynamic_cycle_seen_modes.add(display_mode) + + manager_key = self._build_manager_key(current_mode, current_manager) + self._dynamic_mode_to_manager_key[current_mode] = manager_key + + # Extract league and mode_type from current_mode for duration lookups + league = None + mode_type = None + if current_mode: + if current_mode.startswith('ncaa_mens_'): + league = 'ncaa_mens' + mode_type = current_mode.split('_', 2)[2] + elif current_mode.startswith('ncaa_womens_'): + league = 'ncaa_womens' + mode_type = current_mode.split('_', 2)[2] + + # Log for debugging + self.logger.debug(f"_record_dynamic_progress: current_mode={current_mode}, display_mode={display_mode}, manager={current_manager.__class__.__name__}, manager_key={manager_key}, _last_display_mode={self._last_display_mode}") + + total_games = self._get_total_games_for_manager(current_manager) + + # Check if this is a new cycle for this display mode BEFORE adding to tracking + # A "new cycle" means we're returning to a mode after having been away (different mode) + # Only track external display_mode (from display controller), not internal mode cycling + is_new_cycle = False + current_time = time.time() + + # Only track mode changes for external calls (where display_mode differs from actual_mode) + # This prevents internal mode cycling from triggering new cycle detection + is_external_call = (display_mode and actual_mode and display_mode != actual_mode) + + if is_external_call: + # External call from display controller - check for mode switches + # Only treat as "new cycle" if we've been away for a while (> 10s) + # This allows cycling through recent→upcoming→live→recent without clearing state + NEW_CYCLE_THRESHOLD = 10.0 # seconds + + if display_mode != self._last_display_mode: + # Switched to a different external mode + time_since_last = current_time - self._last_display_mode_time if self._last_display_mode_time > 0 else 999 + + # Only treat as new cycle if we've been away for a while OR this is the first time + if time_since_last >= NEW_CYCLE_THRESHOLD: + is_new_cycle = True + self.logger.info(f"New cycle detected for {display_mode}: switched from {self._last_display_mode} (last seen {time_since_last:.1f}s ago)") + else: + # Quick mode switch within same overall cycle - don't reset + self.logger.debug(f"Quick mode switch to {display_mode} from {self._last_display_mode} ({time_since_last:.1f}s ago) - continuing cycle") + elif manager_key not in self._display_mode_to_managers.get(display_mode, set()): + # Same external mode but manager not tracked yet - could be multi-league setup + self.logger.debug(f"Manager {manager_key} not yet tracked for current mode {display_mode}") + else: + # Same mode and manager already tracked - continue within current cycle + self.logger.debug(f"Continuing cycle for {display_mode}: manager {manager_key} already tracked") + + # Update last display mode tracking (only for external calls) + self._last_display_mode = display_mode + self._last_display_mode_time = current_time + + # ONLY reset state if this is truly a new cycle (after threshold) + if is_new_cycle: + # New cycle starting - reset ALL state for this manager to start completely fresh + if manager_key in self._single_game_manager_start_times: + old_start = self._single_game_manager_start_times[manager_key] + self.logger.info(f"New cycle for {display_mode}: resetting start time for {manager_key} (old: {old_start:.2f})") + del self._single_game_manager_start_times[manager_key] + # Also remove from completed set so it can be tracked fresh in this cycle + if manager_key in self._dynamic_managers_completed: + self.logger.info(f"New cycle for {display_mode}: removing {manager_key} from completed set") + self._dynamic_managers_completed.discard(manager_key) + # Also clear any game ID start times for this manager + if manager_key in self._game_id_start_times: + self.logger.info(f"New cycle for {display_mode}: clearing game ID start times for {manager_key}") + del self._game_id_start_times[manager_key] + # Clear progress tracking for this manager + if manager_key in self._dynamic_manager_progress: + self.logger.info(f"New cycle for {display_mode}: clearing progress for {manager_key}") + self._dynamic_manager_progress[manager_key].clear() + + # Now add to tracking AFTER checking for new cycle + if display_mode and display_mode != current_mode: + # Store mapping from display_mode to manager_key for completion checking + self._display_mode_to_managers.setdefault(display_mode, set()).add(manager_key) + + if total_games <= 1: + # Single (or no) game - wait for full game display duration before marking complete + self._track_single_game_progress(manager_key, current_manager, league, mode_type) + return + + # Get current game to extract its ID for tracking + current_game = getattr(current_manager, "current_game", None) + if not current_game: + # No current game - can't track progress, but this is valid (empty game list) + self.logger.debug(f"No current_game in manager {manager_key}, skipping progress tracking") + # Still mark the mode as seen even if no content + return + + # Use game ID for tracking instead of index to persist across game order changes + game_id = current_game.get('id') + if not game_id: + # Fallback to index if game ID not available (shouldn't happen, but safety first) + current_index = getattr(current_manager, "current_game_index", 0) + # Also try to get a unique identifier from game data + away_abbr = current_game.get('away_abbr', '') + home_abbr = current_game.get('home_abbr', '') + if away_abbr and home_abbr: + game_id = f"{away_abbr}@{home_abbr}-{current_index}" + else: + game_id = f"index-{current_index}" + self.logger.warning(f"Game ID not found for manager {manager_key}, using fallback: {game_id}") + + # Ensure game_id is a string for consistent tracking + game_id = str(game_id) + + progress_set = self._dynamic_manager_progress.setdefault(manager_key, set()) + + # Track when this game ID was first seen + game_times = self._game_id_start_times.setdefault(manager_key, {}) + if game_id not in game_times: + # First time seeing this game - record start time + game_times[game_id] = time.time() + game_duration = self._get_game_duration(league, mode_type, current_manager) if league and mode_type else getattr(current_manager, 'game_display_duration', 15) + game_display = f"{current_game.get('away_abbr', '?')}@{current_game.get('home_abbr', '?')}" + self.logger.info(f"Game {game_display} (ID: {game_id}) in manager {manager_key} first seen, will complete after {game_duration}s") + + # Check if this game has been shown for full duration + start_time = game_times[game_id] + game_duration = self._get_game_duration(league, mode_type, current_manager) if league and mode_type else getattr(current_manager, 'game_display_duration', 15) + elapsed = time.time() - start_time + + if elapsed >= game_duration: + # This game has been shown for full duration - add to progress set + if game_id not in progress_set: + progress_set.add(game_id) + game_display = f"{current_game.get('away_abbr', '?')}@{current_game.get('home_abbr', '?')}" + self.logger.info(f"Game {game_display} (ID: {game_id}) in manager {manager_key} completed after {elapsed:.2f}s (required: {game_duration}s)") + else: + # Still waiting for this game to complete its duration + self.logger.debug(f"Game ID {game_id} in manager {manager_key} waiting: {elapsed:.2f}s/{game_duration}s") + + # Get all valid game IDs from current game list to clean up stale entries + valid_game_ids = self._get_all_game_ids_for_manager(current_manager) + + # Clean up progress set and start times for games that no longer exist + if valid_game_ids: + # Remove game IDs from progress set that are no longer in the game list + progress_set.intersection_update(valid_game_ids) + # Also clean up start times for games that no longer exist + game_times = {k: v for k, v in game_times.items() if k in valid_game_ids} + self._game_id_start_times[manager_key] = game_times + elif total_games == 0: + # No games in list - clear all tracking for this manager + progress_set.clear() + game_times.clear() + self._game_id_start_times[manager_key] = {} + + # Only mark manager complete when all current games have been shown for their full duration + # Use the actual current game IDs, not just the count, to handle dynamic game lists + current_game_ids = self._get_all_game_ids_for_manager(current_manager) + + if current_game_ids: + # Check if all current games have been shown for full duration + if current_game_ids.issubset(progress_set): + if manager_key not in self._dynamic_managers_completed: + self._dynamic_managers_completed.add(manager_key) + self.logger.info(f"Manager {manager_key} completed - all {len(current_game_ids)} games shown for full duration (progress: {len(progress_set)} game IDs)") + else: + missing_count = len(current_game_ids - progress_set) + self.logger.debug(f"Manager {manager_key} incomplete - {missing_count} of {len(current_game_ids)} games not yet shown for full duration") + elif total_games == 0: + # Empty game list - mark as complete immediately + if manager_key not in self._dynamic_managers_completed: + self._dynamic_managers_completed.add(manager_key) + self.logger.debug(f"Manager {manager_key} completed - no games to display") + + def _evaluate_dynamic_cycle_completion(self, display_mode: str = None) -> None: + """ + Determine whether all enabled leagues have completed their cycles for a display mode. + + For sequential block display, a display mode cycle is complete when: + - All enabled leagues for that mode type have completed showing all their games + - Each league is tracked separately via manager keys + + This method checks completion status for all leagues that were used for + the given display mode, ensuring all enabled leagues have completed + before marking the cycle as complete. + + Args: + display_mode: External display mode name (e.g., 'ncaa_mens_recent') + If None, checks internal mode cycling completion + """ + if not self._dynamic_feature_enabled(): + self._dynamic_cycle_complete = True + return + + if not self.modes: + self._dynamic_cycle_complete = True + return + + # If display_mode is provided, check all managers used for that display mode + # This handles multi-league scenarios where we need all leagues to complete + if display_mode and display_mode in self._display_mode_to_managers: + used_manager_keys = self._display_mode_to_managers[display_mode] + if not used_manager_keys: + # No managers were used for this display mode yet - cycle not complete + self._dynamic_cycle_complete = False + self.logger.debug(f"Display mode {display_mode} has no managers tracked yet - cycle incomplete") + return + + # Extract mode type to get enabled leagues for comparison + mode_type = self._extract_mode_type(display_mode) + enabled_leagues = self._get_enabled_leagues_for_mode(mode_type) if mode_type else [] + + self.logger.info( + f"_evaluate_dynamic_cycle_completion for {display_mode}: " + f"checking {len(used_manager_keys)} manager(s): {used_manager_keys}, " + f"enabled leagues: {enabled_leagues}" + ) + + # Check if all managers used for this display mode have completed + incomplete_managers = [] + for manager_key in used_manager_keys: + if manager_key not in self._dynamic_managers_completed: + incomplete_managers.append(manager_key) + # Get the manager to check its state for logging and potential completion + # Extract mode and manager class from manager_key (format: "mode:ManagerClass") + parts = manager_key.split(':', 1) + if len(parts) == 2: + mode_name, manager_class_name = parts + manager = self._get_manager_for_mode(mode_name) + if manager and manager.__class__.__name__ == manager_class_name: + total_games = self._get_total_games_for_manager(manager) + if total_games <= 1: + # Single-game manager - check time + if manager_key in self._single_game_manager_start_times: + start_time = self._single_game_manager_start_times[manager_key] + # Extract league and mode_type from mode_name + league = 'ncaa_mens' if mode_name.startswith('ncaa_mens_') else ('ncaa_womens' if mode_name.startswith('ncaa_womens_') else None) + mode_type_str = mode_name.split('_')[-1] if mode_name else None + game_duration = self._get_game_duration(league, mode_type_str, manager) if league and mode_type_str else getattr(manager, 'game_display_duration', 15) + current_time = time.time() + elapsed = current_time - start_time + if elapsed >= game_duration: + self._dynamic_managers_completed.add(manager_key) + incomplete_managers.remove(manager_key) + self.logger.info(f"Manager {manager_key} marked complete in completion check: {elapsed:.2f}s >= {game_duration}s") + # Clean up start time now that manager has completed + if manager_key in self._single_game_manager_start_times: + del self._single_game_manager_start_times[manager_key] + else: + self.logger.debug(f"Manager {manager_key} waiting in completion check: {elapsed:.2f}s/{game_duration}s (start_time={start_time:.2f}, current_time={current_time:.2f})") + else: + # Manager not yet seen - keep it incomplete + # This means _record_dynamic_progress hasn't been called yet for this manager + # or the state was reset, so we can't determine completion + self.logger.debug(f"Manager {manager_key} not yet seen in completion check (not in start_times) - keeping incomplete") + + if incomplete_managers: + self._dynamic_cycle_complete = False + self.logger.debug(f"Display mode {display_mode} cycle incomplete - {len(incomplete_managers)} manager(s) still in progress: {incomplete_managers}") + return + + # All managers completed - verify they truly completed + # Double-check that single-game managers have truly finished their duration + all_truly_completed = True + for manager_key in used_manager_keys: + # If manager has a start time, it hasn't completed yet (or just completed) + if manager_key in self._single_game_manager_start_times: + # Still has start time - check if it should be completed + parts = manager_key.split(':', 1) + if len(parts) == 2: + mode_name, manager_class_name = parts + manager = self._get_manager_for_mode(mode_name) + if manager and manager.__class__.__name__ == manager_class_name: + start_time = self._single_game_manager_start_times[manager_key] + # Extract league and mode_type from mode_name + league = 'ncaa_mens' if mode_name.startswith('ncaa_mens_') else ('ncaa_womens' if mode_name.startswith('ncaa_womens_') else None) + mode_type_str = mode_name.split('_')[-1] if mode_name else None + game_duration = self._get_game_duration(league, mode_type_str, manager) if league and mode_type_str else getattr(manager, 'game_display_duration', 15) + elapsed = time.time() - start_time + if elapsed < game_duration: + # Not enough time has passed - not truly completed + all_truly_completed = False + self.logger.debug(f"Manager {manager_key} in completed set but still has start time with {elapsed:.2f}s < {game_duration}s") + break + + if all_truly_completed: + self._dynamic_cycle_complete = True + self.logger.info(f"Display mode {display_mode} cycle complete - all {len(used_manager_keys)} manager(s) completed") + else: + # Some managers aren't truly completed - keep cycle incomplete + self._dynamic_cycle_complete = False + self.logger.debug(f"Display mode {display_mode} cycle incomplete - some managers not truly completed yet") + return + + # Standard mode checking (for internal mode cycling) + required_modes = [mode for mode in self.modes if mode] + if not required_modes: + self._dynamic_cycle_complete = True + return + + for mode_name in required_modes: + if mode_name not in self._dynamic_cycle_seen_modes: + self._dynamic_cycle_complete = False + return + + manager_key = self._dynamic_mode_to_manager_key.get(mode_name) + if not manager_key: + self._dynamic_cycle_complete = False + return + + if manager_key not in self._dynamic_managers_completed: + manager = self._get_manager_for_mode(mode_name) + total_games = self._get_total_games_for_manager(manager) + if total_games <= 1: + # For single-game managers, check if enough time has passed + if manager_key in self._single_game_manager_start_times: + start_time = self._single_game_manager_start_times[manager_key] + game_duration = getattr(manager, 'game_display_duration', 15) if manager else 15 + elapsed = time.time() - start_time + if elapsed >= game_duration: + self._dynamic_managers_completed.add(manager_key) + else: + # Not enough time yet + self._dynamic_cycle_complete = False + return + else: + # Haven't seen this manager yet in _record_dynamic_progress + self._dynamic_cycle_complete = False + return + else: + # Multi-game manager - check if all current games have been shown for full duration + progress_set = self._dynamic_manager_progress.get(manager_key, set()) + current_game_ids = self._get_all_game_ids_for_manager(manager) + + # Check if all current games are in the progress set (shown for full duration) + if current_game_ids and current_game_ids.issubset(progress_set): + self._dynamic_managers_completed.add(manager_key) + # Continue to check other modes + else: + missing_games = current_game_ids - progress_set if current_game_ids else set() + self.logger.debug(f"Manager {manager_key} progress: {len(progress_set)}/{len(current_game_ids)} games completed, missing: {len(missing_games)}") + self._dynamic_cycle_complete = False + return + + self._dynamic_cycle_complete = True + + def supports_dynamic_duration(self) -> bool: + """ + Check if dynamic duration is enabled for the current display context. + Checks granular settings: per-league/per-mode > per-mode > per-league > global. + """ + if not self.is_enabled: + return False + + # If no current display context, return False (no global fallback) + 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 + + # Check per-league/per-mode setting first (most specific) + 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)) + + # Check per-league setting + if "enabled" in league_dynamic: + return bool(league_dynamic.get("enabled", False)) + + # No global fallback - return False + return False + + def get_dynamic_duration_cap(self) -> Optional[float]: + """ + Get dynamic duration cap for the current display context. + Checks granular settings: per-league/per-mode > per-mode > per-league > global. + """ + if not self.is_enabled: + return None + + # If no current display context, return None (no global fallback) + if not self._current_display_league or not self._current_display_mode_type: + return None + + league = self._current_display_league + mode_type = self._current_display_mode_type + + # Check per-league/per-mode setting first (most specific) + 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 "max_duration_seconds" in mode_config: + try: + cap = float(mode_config.get("max_duration_seconds")) + if cap > 0: + return cap + except (TypeError, ValueError): + pass + + # Check per-league setting + if "max_duration_seconds" in league_dynamic: + try: + cap = float(league_dynamic.get("max_duration_seconds")) + if cap > 0: + return cap + except (TypeError, ValueError): + pass + + # No global fallback - return None + return None + + def has_live_priority(self) -> bool: + if not self.is_enabled: + return False + + return any( + [ + self.ncaa_mens_enabled and self.ncaa_mens_live_priority, + self.ncaa_womens_enabled and self.ncaa_womens_live_priority, + ] + ) + + def has_live_content(self) -> bool: + if not self.is_enabled: + return False + + # Check NCAA Men's live content + ncaa_mens_live = False + if ( + self.ncaa_mens_enabled + and self.ncaa_mens_live_priority + and hasattr(self, "ncaa_mens_live") + ): + live_games = getattr(self.ncaa_mens_live, "live_games", []) + if live_games: + # Filter out any games that are final or appear over + live_games = [g for g in live_games if not g.get("is_final", False)] + # Additional validation using helper method if available + if hasattr(self.ncaa_mens_live, "_is_game_really_over"): + live_games = [g for g in live_games if not self.ncaa_mens_live._is_game_really_over(g)] + + if live_games: + # If favorite teams are configured, only return True if there are live games for favorite teams + favorite_teams = getattr(self.ncaa_mens_live, "favorite_teams", []) + if favorite_teams: + # Check if any live game involves a favorite team + ncaa_mens_live = any( + game.get("home_abbr") in favorite_teams + or game.get("away_abbr") in favorite_teams + for game in live_games + ) + else: + # No favorite teams configured, return True if any live games exist + ncaa_mens_live = True + + # Check NCAA Women's live content + ncaa_womens_live = False + if ( + self.ncaa_womens_enabled + and self.ncaa_womens_live_priority + and hasattr(self, "ncaa_womens_live") + ): + live_games = getattr(self.ncaa_womens_live, "live_games", []) + if live_games: + # Filter out any games that are final or appear over + live_games = [g for g in live_games if not g.get("is_final", False)] + # Additional validation using helper method if available + if hasattr(self.ncaa_womens_live, "_is_game_really_over"): + live_games = [g for g in live_games if not self.ncaa_womens_live._is_game_really_over(g)] + + if live_games: + # If favorite teams are configured, only return True if there are live games for favorite teams + favorite_teams = getattr(self.ncaa_womens_live, "favorite_teams", []) + if favorite_teams: + # Check if any live game involves a favorite team + ncaa_womens_live = any( + game.get("home_abbr") in favorite_teams + or game.get("away_abbr") in favorite_teams + for game in live_games + ) + else: + # No favorite teams configured, return True if any live games exist + ncaa_womens_live = True + + result = ncaa_mens_live or ncaa_womens_live + + # Throttle logging when returning False to reduce log noise + # Always log True immediately (important), but only log False every 60 seconds + current_time = time.time() + should_log = result or (current_time - self._last_live_content_false_log >= self._live_content_log_interval) + + if should_log: + self.logger.info( + f"has_live_content() returning {result}: " + f"ncaa_mens_live={ncaa_mens_live}, ncaa_womens_live={ncaa_womens_live}" + ) + if not result: + self._last_live_content_false_log = current_time + + return result + + def get_live_modes(self) -> list: + """ + Return the registered plugin mode name(s) that have live content. + + Returns granular live modes (ncaa_mens_live, ncaa_womens_live) that have live content. + The plugin is registered with granular modes in manifest.json. + """ + if not self.is_enabled: + return [] + + live_modes = [] + + # Check NCAA Men's live content + if ( + self.ncaa_mens_enabled + and self.ncaa_mens_live_priority + and hasattr(self, "ncaa_mens_live") + ): + live_games = getattr(self.ncaa_mens_live, "live_games", []) + if live_games: + # Filter out any games that are final or appear over + live_games = [g for g in live_games if not g.get("is_final", False)] + # Additional validation using helper method if available + if hasattr(self.ncaa_mens_live, "_is_game_really_over"): + live_games = [g for g in live_games if not self.ncaa_mens_live._is_game_really_over(g)] + + if live_games: + # Check if favorite teams filter applies + favorite_teams = getattr(self.ncaa_mens_live, "favorite_teams", []) + if favorite_teams: + # Only include if there are live games for favorite teams + if any( + game.get("home_abbr") in favorite_teams + or game.get("away_abbr") in favorite_teams + for game in live_games + ): + live_modes.append("ncaa_mens_live") + else: + # No favorite teams configured, include if any live games exist + live_modes.append("ncaa_mens_live") + + # Check NCAA Women's live content + if ( + self.ncaa_womens_enabled + and self.ncaa_womens_live_priority + and hasattr(self, "ncaa_womens_live") + ): + live_games = getattr(self.ncaa_womens_live, "live_games", []) + if live_games: + # Filter out any games that are final or appear over + live_games = [g for g in live_games if not g.get("is_final", False)] + # Additional validation using helper method if available + if hasattr(self.ncaa_womens_live, "_is_game_really_over"): + live_games = [g for g in live_games if not self.ncaa_womens_live._is_game_really_over(g)] + + if live_games: + # Check if favorite teams filter applies + favorite_teams = getattr(self.ncaa_womens_live, "favorite_teams", []) + if favorite_teams: + # Only include if there are live games for favorite teams + if any( + game.get("home_abbr") in favorite_teams + or game.get("away_abbr") in favorite_teams + for game in live_games + ): + live_modes.append("ncaa_womens_live") + else: + # No favorite teams configured, include if any live games exist + live_modes.append("ncaa_womens_live") + + return live_modes + + def _should_use_scroll_mode(self, league: str, mode_type: str) -> bool: + """ + Check if a specific league should use scroll mode for this game type. + + Args: + league: League ID ('ncaa_mens' or 'ncaa_womens') + mode_type: 'live', 'recent', or 'upcoming' + + Returns: + True if this league uses scroll mode for this game type + """ + return self._get_display_mode(league, mode_type) == 'scroll' + + def _display_scroll_mode(self, display_mode: str, league: str, mode_type: str, force_clear: bool) -> bool: + """Handle display for scroll mode (single league). + + Args: + display_mode: External mode name (e.g., 'ncaa_mens_recent') + league: League ID ('ncaa_mens' or 'ncaa_womens') + mode_type: Game type ('live', 'recent', 'upcoming') + force_clear: Whether to force clear display + + Returns: + True if content was displayed, False otherwise + """ + if not self._scroll_manager: + self.logger.warning("Scroll mode requested but scroll manager not available") + # Fall back to switch mode + return self._try_manager_display( + self._get_league_manager_for_mode(league, mode_type), + force_clear, + display_mode, + mode_type, + None + )[0] + + # Check if we need to prepare new scroll content + scroll_key = f"{display_mode}_{mode_type}" + + if not self._scroll_prepared.get(scroll_key, False): + # Get manager and update it + 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 + + self._ensure_manager_updated(manager) + + # Get games from this manager + games = self._get_games_from_manager(manager, mode_type) + + if not games: + self.logger.debug(f"No games to scroll for {display_mode}") + self._scroll_prepared[scroll_key] = False + self._scroll_active[scroll_key] = False + return False + + # Add league info to each game + for game in games: + game['league'] = league + + # Get rankings cache for display + rankings = self._get_rankings_cache() + + # Prepare scroll content (single league) + success = self._scroll_manager.prepare_and_display( + games, mode_type, [league], rankings + ) + + if success: + self._scroll_prepared[scroll_key] = True + self._scroll_active[scroll_key] = True + self.logger.info( + f"[Lacrosse Scroll] Started scrolling {len(games)} {league} {mode_type} games" + ) + else: + self._scroll_prepared[scroll_key] = False + self._scroll_active[scroll_key] = False + return False + + # Display the next scroll frame + if self._scroll_active.get(scroll_key, False): + displayed = self._scroll_manager.display_frame(mode_type) + + if displayed: + # Check if scroll is complete + if self._scroll_manager.is_complete(mode_type): + self.logger.info(f"[Lacrosse Scroll] Cycle complete for {display_mode}") + # Reset for next cycle + self._scroll_prepared[scroll_key] = False + self._scroll_active[scroll_key] = False + # Mark cycle as complete for dynamic duration + self._dynamic_cycle_complete = True + + return True + else: + # Scroll display failed + self._scroll_active[scroll_key] = False + return False + + return False + + def _display_league_mode(self, league: str, mode_type: str, force_clear: bool) -> bool: + """ + Display a specific league/mode combination (e.g., NCAA Men's Recent, NCAA Women's Upcoming). + + This method displays content from a single league and mode type, used when + rotation_order specifies granular modes like 'ncaa_mens_recent' or 'ncaa_womens_upcoming'. + + Args: + league: League ID ('ncaa_mens' or 'ncaa_womens') + mode_type: Mode type ('live', 'recent', or 'upcoming') + force_clear: Whether to force clear display + + Returns: + True if content was displayed, False otherwise + """ + # Validate league + if league not in self._league_registry: + self.logger.warning(f"Invalid league in _display_league_mode: {league}") + return False + + # Check if league is enabled + if not self._league_registry[league].get('enabled', False): + self.logger.debug(f"League {league} is disabled, skipping") + return False + + # Get manager for this league/mode combination + 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 + + # Create display mode name for tracking + display_mode = f"{league}_{mode_type}" + + # Check if this league uses scroll mode + if self._should_use_scroll_mode(league, mode_type): + return self._display_scroll_mode(display_mode, league, mode_type, force_clear) + + # Set display context for dynamic duration tracking + self._current_display_league = league + self._current_display_mode_type = mode_type + + # Try to display content from this league's manager (switch mode) + success, _ = self._try_manager_display( + manager, force_clear, display_mode, mode_type, None + ) + + # Only track mode start time and check duration if we actually have content to display + if success: + # Track mode start time for per-mode duration enforcement (only when content exists) + if display_mode not in self._mode_start_time: + self._mode_start_time[display_mode] = time.time() + self.logger.debug(f"Started tracking time for {display_mode}") + + # Check if mode-level duration has expired (only check if we have content) + effective_mode_duration = self._get_effective_mode_duration(display_mode, mode_type) + if effective_mode_duration is not None: + elapsed_time = time.time() - self._mode_start_time[display_mode] + if elapsed_time >= effective_mode_duration: + # Mode duration expired - time to rotate + self.logger.info( + f"Mode duration expired for {display_mode}: " + f"{elapsed_time:.1f}s >= {effective_mode_duration}s. " + f"Rotating to next mode (progress preserved for resume)." + ) + # Reset mode start time for next cycle + self._mode_start_time[display_mode] = time.time() + return False + + self.logger.debug( + f"Displayed content from {league} {mode_type} (mode: {display_mode})" + ) + else: + # No content - clear any existing start time so mode can start fresh when content becomes available + if display_mode in self._mode_start_time: + del self._mode_start_time[display_mode] + self.logger.debug(f"Cleared mode start time for {display_mode} (no content available)") + + self.logger.debug( + f"No content available for {league} {mode_type} (mode: {display_mode})" + ) + + return success + + def _display_internal_cycling(self, force_clear: bool) -> bool: + """Handle display for internal mode cycling (when no display_mode provided). + + Args: + force_clear: Whether to force clear display + + Returns: + True if content was displayed, False otherwise + """ + current_time = time.time() + + # Check if we should stay on live mode + should_stay_on_live = False + if self.has_live_content(): + # Get current mode name + current_mode = self.modes[self.current_mode_index] if self.modes else None + # If we're on a live mode, stay there + if current_mode and current_mode.endswith('_live'): + should_stay_on_live = True + # If we're not on a live mode but have live content, switch to it + elif not (current_mode and current_mode.endswith('_live')): + # Find the first live mode + for i, mode in enumerate(self.modes): + if mode.endswith('_live'): + self.current_mode_index = i + force_clear = True + self.last_mode_switch = current_time + self.logger.info(f"Live content detected - switching to display mode: {mode}") + break + + # Handle mode cycling only if not staying on live + if not should_stay_on_live 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 + force_clear = True + + current_mode = self.modes[self.current_mode_index] + self.logger.info(f"Switching to display mode: {current_mode}") + + # Get current manager and display + current_manager = self._get_current_manager() + if not current_manager: + self.logger.warning("No manager available for current mode") + return False + + # Track which league/mode we're displaying for granular dynamic duration + current_mode = self.modes[self.current_mode_index] if self.modes else None + if current_mode: + # Extract mode type from mode name + mode_type = self._extract_mode_type(current_mode) + if mode_type: + self._set_display_context_from_manager(current_manager, mode_type) + + result = current_manager.display(force_clear) + if result is not False: + try: + # Build the actual mode name from league and mode_type for accurate tracking + current_mode = self.modes[self.current_mode_index] if self.modes else None + if current_mode: + manager_key = self._build_manager_key(current_mode, current_manager) + # Track which managers were used for internal mode cycling + # For internal cycling, the mode itself is the display_mode + self._display_mode_to_managers.setdefault(current_mode, set()).add(manager_key) + self._record_dynamic_progress( + current_manager, actual_mode=current_mode, display_mode=current_mode + ) + except Exception as progress_err: # pylint: disable=broad-except + self.logger.debug(f"Dynamic progress tracking failed: {progress_err}") + else: + # Manager returned False (no content) - ensure display is cleared + # This is a safety measure in case the manager didn't clear it + if force_clear: + try: + self.display_manager.clear() + self.display_manager.update_display() + except Exception as clear_err: + self.logger.debug(f"Error clearing display when manager returned False: {clear_err}") + + current_mode = self.modes[self.current_mode_index] if self.modes else None + self._evaluate_dynamic_cycle_completion(display_mode=current_mode) + return result + + def _try_manager_display( + self, + manager, + force_clear: bool, + display_mode: str, + mode_type: str, + sticky_manager=None + ) -> Tuple[bool, Optional[str]]: + """ + Try to display content from a single manager. + + This method handles displaying content from a manager and tracking progress + for dynamic duration. It uses sticky manager logic to ensure all games from + one league are displayed before switching to another. + + Args: + manager: Manager instance to try + force_clear: Whether to force clear display + display_mode: External display mode name (e.g., 'ncaa_mens_recent') + mode_type: Mode type ('live', 'recent', or 'upcoming') + sticky_manager: Deprecated parameter (kept for compatibility, ignored) + + Returns: + Tuple of (success: bool, actual_mode: Optional[str]) + - success: True if manager displayed content, False otherwise + - actual_mode: The actual mode name used for tracking (e.g., 'ncaa_mens_recent') + """ + if not manager: + return False, None + + # Track which league we're displaying for granular dynamic duration + # This sets _current_display_league and _current_display_mode_type + # which are used for progress tracking and duration calculations + self._set_display_context_from_manager(manager, mode_type) + + # Ensure manager is updated before displaying + # This fetches fresh data if needed based on update intervals + self._ensure_manager_updated(manager) + + # Attempt to display content from this manager + # Manager returns True if it has content to show, False if no content + result = manager.display(force_clear) + + # Build the actual mode name from league and mode_type for accurate tracking + # This is used to track progress per league separately + # Example: 'ncaa_mens_recent' or 'ncaa_womens_live' + actual_mode = ( + f"{self._current_display_league}_{mode_type}" + if self._current_display_league and mode_type + else display_mode + ) + + # Track game transitions for logging + # Only log at DEBUG level for frequent calls, INFO for game transitions + manager_class_name = manager.__class__.__name__ + has_current_game = hasattr(manager, 'current_game') and manager.current_game is not None + current_game = getattr(manager, 'current_game', None) if has_current_game else None + + # Get current game ID for transition detection + current_game_id = None + if current_game: + current_game_id = current_game.get('id') or current_game.get('game_id') + if not current_game_id: + # Fallback: create ID from team abbreviations + away = current_game.get('away_abbr', '') + home = current_game.get('home_abbr', '') + if away and home: + current_game_id = f"{away}@{home}" + + # Check for game transition + game_tracking = self._current_game_tracking.get(display_mode, {}) + last_game_id = game_tracking.get('game_id') + last_league = game_tracking.get('league') + last_log_time = game_tracking.get('last_log_time', 0.0) + current_time = time.time() + + # Detect game transition or league change + game_changed = (current_game_id and current_game_id != last_game_id) + league_changed = (self._current_display_league and self._current_display_league != last_league) + time_since_last_log = current_time - last_log_time + + # Log game transitions at INFO level (but throttle to avoid spam) + if (game_changed or league_changed) and time_since_last_log >= self._game_transition_log_interval: + if game_changed and current_game_id: + away_abbr = current_game.get('away_abbr', '?') if current_game else '?' + home_abbr = current_game.get('home_abbr', '?') if current_game else '?' + self.logger.info( + f"Game transition in {display_mode}: " + f"{away_abbr} @ {home_abbr} " + f"({self._current_display_league or 'unknown'} {mode_type})" + ) + elif league_changed and self._current_display_league: + self.logger.info( + f"League transition in {display_mode}: " + f"switched to {self._current_display_league} {mode_type}" + ) + + # Update tracking + self._current_game_tracking[display_mode] = { + 'game_id': current_game_id, + 'league': self._current_display_league, + 'last_log_time': current_time + } + else: + # Frequent calls - only log at DEBUG level + self.logger.debug( + f"Manager {manager_class_name} display() returned {result}, " + f"has_current_game={has_current_game}, game_id={current_game_id}" + ) + + if result is True: + # Success - track progress and set sticky manager + 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 as progress_err: # pylint: disable=broad-except + self.logger.debug(f"Dynamic progress tracking failed: {progress_err}") + + # Set as sticky manager AFTER progress tracking (which may clear it on new cycle) + if display_mode not in self._sticky_manager_per_mode: + self._sticky_manager_per_mode[display_mode] = manager + self._sticky_manager_start_time[display_mode] = time.time() + self.logger.info(f"Set sticky manager {manager_class_name} for {display_mode}") + + # Track which managers were used for this display mode + 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 + + elif result is False and manager == sticky_manager: + # Sticky manager returned False - check if completed + manager_key = self._build_manager_key(actual_mode, manager) + + if manager_key in self._dynamic_managers_completed: + self.logger.info( + f"Sticky manager {manager_class_name} completed all games, switching to next manager" + ) + self._sticky_manager_per_mode.pop(display_mode, None) + self._sticky_manager_start_time.pop(display_mode, None) + # Signal to break out of loop and try next manager + return False, None + else: + # Manager not done yet, just returning False temporarily (between game switches) + self.logger.debug( + f"Sticky manager {manager_class_name} returned False (between games), continuing" + ) + return False, None + + elif result is False: + # Non-sticky manager returned False - try next + return False, None + + else: + # Result is None or other - assume success + 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 as progress_err: # pylint: disable=broad-except + self.logger.debug(f"Dynamic progress tracking failed: {progress_err}") + + # Track which managers were used for this display mode + 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 + + def _get_effective_mode_duration(self, display_mode: str, mode_type: str) -> Optional[float]: + """ + Get effective mode duration for a display mode. + + Checks per-mode duration settings first, then falls back to dynamic calculation. + + Args: + display_mode: Display mode name (e.g., 'ncaa_mens_recent') + mode_type: Mode type ('live', 'recent', or 'upcoming') + + Returns: + Mode duration in seconds (float) or None to use dynamic calculation + """ + if not self._current_display_league: + return None + + # Get mode duration from config + mode_duration = self._get_mode_duration(self._current_display_league, mode_type) + if mode_duration is not None: + return mode_duration + + # No per-mode duration configured - use dynamic calculation + return None + + def validate_config(self) -> bool: + """Validate plugin configuration.""" + try: + # Check that at least one league is enabled + if not (self.ncaa_mens_enabled or self.ncaa_womens_enabled): + self.logger.warning("No leagues enabled in lacrosse scoreboard plugin") + return False + + return True + except Exception as e: + self.logger.error(f"Error validating config: {e}") + return False + + def get_display_duration(self) -> float: + """Get the display duration for this plugin.""" + return float(self.display_duration) + + def get_cycle_duration(self, display_mode: str = None) -> Optional[float]: + """ + Calculate the expected cycle duration for a display mode based on the number of games. + + This implements dynamic duration scaling with support for mode-level durations: + - Mode-level duration: Fixed total time for mode (recent_mode_duration, upcoming_mode_duration, live_mode_duration) + - Dynamic calculation: Total duration = num_games × per_game_duration + + Priority order: + 1. Mode-level duration (if configured) + 2. Dynamic calculation (if no mode-level duration) + 3. Dynamic duration cap applies to both if enabled + + Args: + display_mode: The display mode to calculate duration for (e.g., 'ncaa_mens_live', 'ncaa_mens_recent', 'ncaa_womens_upcoming') + + Returns: + Total expected duration in seconds, or None if not applicable + """ + self.logger.info(f"get_cycle_duration() called with display_mode={display_mode}, is_enabled={self.is_enabled}") + if not self.is_enabled or not display_mode: + self.logger.info(f"get_cycle_duration() returning None: is_enabled={self.is_enabled}, display_mode={display_mode}") + return None + + # Extract mode type and league (if granular mode) + mode_type = self._extract_mode_type(display_mode) + if not mode_type: + return None + + # Parse granular mode name if applicable (e.g., "ncaa_mens_recent", "ncaa_womens_upcoming") + league = None + if "_" in display_mode and not display_mode.startswith("lacrosse_"): + # Granular mode: extract league + # Handle ncaa_mens and ncaa_womens with multiple underscores + if display_mode.startswith("ncaa_mens_"): + league = "ncaa_mens" + elif display_mode.startswith("ncaa_womens_"): + league = "ncaa_womens" + else: + # Try standard split + parts = display_mode.split("_", 1) + if len(parts) == 2: + potential_league, potential_mode_type = parts + if potential_league in self._league_registry and potential_mode_type == mode_type: + league = potential_league + + # Check for mode-level duration first (priority 1) + # Extract league if not already determined + if not league and mode_type: + # Try to get league from current display context or parse from display_mode + if self._current_display_league: + league = self._current_display_league + else: + # Try to parse from display_mode + if display_mode.startswith("ncaa_mens_"): + league = "ncaa_mens" + elif display_mode.startswith("ncaa_womens_"): + league = "ncaa_womens" + + if league: + effective_mode_duration = self._get_mode_duration(league, mode_type) + if effective_mode_duration is not None: + self.logger.info( + f"get_cycle_duration: using mode-level duration for {display_mode} = {effective_mode_duration}s" + ) + return effective_mode_duration + + # Fall through to dynamic calculation based on game count (priority 2) + + try: + self.logger.info(f"get_cycle_duration: extracted mode_type={mode_type}, league={league} from display_mode={display_mode}") + + total_games = 0 + total_duration = 0.0 # Accumulate duration per-league to handle different per_game_durations + + # Collect managers for this mode and count their games + managers_to_check = [] + + # If granular mode (specific league), only check that league + if league: + manager = self._get_league_manager_for_mode(league, mode_type) + if manager: + managers_to_check.append((league, manager)) + else: + # Combined mode - check all enabled leagues for this mode_type + for lg in ('ncaa_mens', 'ncaa_womens'): + if self._league_registry.get(lg, {}).get('enabled'): + mgr = self._get_league_manager_for_mode(lg, mode_type) + if mgr: + managers_to_check.append((lg, mgr)) + + # CRITICAL: Update managers BEFORE checking game counts! + self.logger.info(f"get_cycle_duration: updating {len(managers_to_check)} manager(s) before counting games") + for league_name, manager in managers_to_check: + if manager: + self._ensure_manager_updated(manager) + + # Count games from all applicable managers and get duration + for league_name, manager in managers_to_check: + if not manager: + continue + + # Get the appropriate game list based on mode type + if mode_type == 'live': + games = getattr(manager, 'live_games', []) + elif mode_type == 'recent': + # Try games_list first (used by recent managers), then recent_games + games = getattr(manager, 'games_list', None) + if games is None: + games = getattr(manager, 'recent_games', []) + else: + games = list(games) if games else [] + elif mode_type == 'upcoming': + # Try games_list first (used by upcoming managers), then upcoming_games + games = getattr(manager, 'games_list', None) + if games is None: + games = getattr(manager, 'upcoming_games', []) + else: + games = list(games) if games else [] + else: + games = [] + + # Get duration for this league/mode combination + per_game_duration = self._get_game_duration(league_name, mode_type, manager) + + # Filter out invalid games + if games: + # For live games, filter out final games + if mode_type == 'live': + games = [g for g in games if not g.get('is_final', False)] + if hasattr(manager, '_is_game_really_over'): + games = [g for g in games if not manager._is_game_really_over(g)] + + game_count = len(games) + total_games += game_count + # Accumulate duration per-league to correctly handle different per_game_durations + total_duration += game_count * per_game_duration + + self.logger.debug( + f"get_cycle_duration: {league_name} {mode_type} has {game_count} games × " + f"{per_game_duration}s = {game_count * per_game_duration}s" + ) + + self.logger.info(f"get_cycle_duration: found {total_games} total games for {display_mode}") + + if total_games == 0: + # If no games found yet (managers still fetching data), return a default duration + # This allows the display to start while data is loading + default_duration = 45.0 # 3 games × 15s per game (reasonable default) + self.logger.info(f"get_cycle_duration: {display_mode} has no games yet, returning default {default_duration}s") + return default_duration + + self.logger.info( + f"get_cycle_duration({display_mode}): {total_games} total games = {total_duration}s" + ) + + return total_duration + + except Exception as e: + self.logger.error(f"Error calculating cycle duration for {display_mode}: {e}", exc_info=True) + return None + + def get_info(self) -> Dict[str, Any]: + """Get plugin information.""" + try: + current_manager = self._get_current_manager() + current_mode = self.modes[self.current_mode_index] if self.modes else "none" + + info = { + "plugin_id": self.plugin_id, + "name": "Lacrosse Scoreboard", + "version": "1.0.1", + "enabled": self.is_enabled, + "display_size": f"{self.display_width}x{self.display_height}", + "ncaa_mens_enabled": self.ncaa_mens_enabled, + "ncaa_womens_enabled": self.ncaa_womens_enabled, + "current_mode": current_mode, + "available_modes": self.modes, + "display_duration": self.display_duration, + "game_display_duration": self.game_display_duration, + "show_records": getattr(self, 'show_records', False), + "show_ranking": getattr(self, 'show_ranking', False), + "show_odds": getattr(self, 'show_odds', False), + "managers_initialized": { + "ncaa_mens_live": hasattr(self, "ncaa_mens_live"), + "ncaa_mens_recent": hasattr(self, "ncaa_mens_recent"), + "ncaa_mens_upcoming": hasattr(self, "ncaa_mens_upcoming"), + "ncaa_womens_live": hasattr(self, "ncaa_womens_live"), + "ncaa_womens_recent": hasattr(self, "ncaa_womens_recent"), + "ncaa_womens_upcoming": hasattr(self, "ncaa_womens_upcoming"), + }, + "live_priority": { + "ncaa_mens": self.ncaa_mens_enabled + and self.ncaa_mens_live_priority, + "ncaa_womens": self.ncaa_womens_enabled + and self.ncaa_womens_live_priority, + }, + } + + # Add manager-specific info if available + if current_manager and hasattr(current_manager, "get_info"): + try: + manager_info = current_manager.get_info() + info["current_manager_info"] = manager_info + except Exception as e: + info["current_manager_info"] = f"Error getting manager info: {e}" + + return info + + except Exception as e: + self.logger.error(f"Error getting plugin info: {e}") + return { + "plugin_id": self.plugin_id, + "name": "Lacrosse Scoreboard", + "error": str(e), + } + + # ------------------------------------------------------------------------- + # Scroll mode helper methods + # ------------------------------------------------------------------------- + def _is_scroll_mode_available(self) -> bool: + """ + Check if scroll mode is available (scroll manager exists). + + Returns: + True if scroll mode is available, False otherwise + """ + return bool(self._scroll_manager) + + def _collect_games_for_scroll(self) -> tuple: + """ + Collect all games for scroll mode from enabled leagues. + + Collects live, recent, and upcoming games organized by league. + Within each league, games are sorted: live first, then recent, then upcoming. + + Returns: + Tuple of (games_list, leagues_list) + """ + all_games = [] + leagues = [] + + sport_key_for_league = { + 'ncaa_mens': 'ncaam_lacrosse', + 'ncaa_womens': 'ncaaw_lacrosse', + } + + for league_id, sport_key in sport_key_for_league.items(): + if not self._league_registry.get(league_id, {}).get('enabled'): + continue + league_games = [] + for mode_type in ['live', 'recent', 'upcoming']: + manager = self._get_manager_for_league_mode(sport_key, mode_type) + if not manager: + continue + games = self._get_games_from_manager(manager, mode_type) + for game in games: + game['league'] = sport_key + if 'status' not in game: + game['status'] = {} + if 'state' not in game['status']: + state_map = {'live': 'in', 'recent': 'post', 'upcoming': 'pre'} + game['status']['state'] = state_map.get(mode_type, 'pre') + league_games.extend(games) + + if league_games: + all_games.extend(league_games) + leagues.append(sport_key) + + return all_games, leagues + + def _get_manager_for_league_mode(self, league: str, mode_type: str): + """Get manager for a specific league and mode type. + + Accepts either plugin league IDs ('ncaa_mens', 'ncaa_womens') or + sport-key form ('ncaam_lacrosse', 'ncaaw_lacrosse'). + """ + if league in ('ncaa_mens', 'ncaam_lacrosse'): + if mode_type == 'live': + return getattr(self, 'ncaa_mens_live', None) + if mode_type == 'recent': + return getattr(self, 'ncaa_mens_recent', None) + if mode_type == 'upcoming': + return getattr(self, 'ncaa_mens_upcoming', None) + elif league in ('ncaa_womens', 'ncaaw_lacrosse'): + if mode_type == 'live': + return getattr(self, 'ncaa_womens_live', None) + if mode_type == 'recent': + return getattr(self, 'ncaa_womens_recent', None) + if mode_type == 'upcoming': + return getattr(self, 'ncaa_womens_upcoming', None) + return None + + # ------------------------------------------------------------------------- + # Vegas scroll mode support + # ------------------------------------------------------------------------- + def get_vegas_content(self) -> Optional[Any]: + """ + Get content for Vegas-style continuous scroll mode. + + Triggers scroll content generation if cache is empty, then returns + the cached scroll image(s) for Vegas to compose into its scroll strip. + + Returns: + List of PIL Images from scroll displays, or None if no content + """ + if not hasattr(self, '_scroll_manager') or not self._scroll_manager: + return None + + images = self._scroll_manager.get_all_vegas_content_items() + + if not images: + self.logger.info("[Lacrosse Vegas] Triggering scroll content generation") + self._ensure_scroll_content_for_vegas() + images = self._scroll_manager.get_all_vegas_content_items() + + if images: + total_width = sum(img.width for img in images) + self.logger.info( + "[Lacrosse Vegas] Returning %d image(s), %dpx total", + len(images), total_width + ) + return images + + return None + + def get_vegas_content_type(self) -> str: + """ + Indicate the type of content this plugin provides for Vegas scroll. + + Returns: + 'multi' - Plugin has multiple scrollable items (games) + """ + return 'multi' + + def get_vegas_display_mode(self) -> 'VegasDisplayMode': + """ + Get the display mode for Vegas scroll integration. + + Returns: + VegasDisplayMode.SCROLL - Content scrolls continuously + """ + if VegasDisplayMode: + # Check for config override + 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}' in config, using SCROLL" + ) + return VegasDisplayMode.SCROLL + # Fallback if VegasDisplayMode not available + return "scroll" + + def _ensure_scroll_content_for_vegas(self) -> None: + """ + Ensure scroll content is generated for Vegas mode. + + This method is called by get_vegas_content() when the scroll cache is empty. + It collects all game types (live, recent, upcoming) organized by league. + """ + if not hasattr(self, '_scroll_manager') or not self._scroll_manager: + self.logger.debug("[Lacrosse Vegas] No scroll manager available") + return + + # Collect all games (live, recent, upcoming) organized by league + games, leagues = self._collect_games_for_scroll() + + if not games: + self.logger.debug("[Lacrosse Vegas] No games available") + return + + # Count games by type for logging + game_type_counts = {'live': 0, 'recent': 0, 'upcoming': 0} + for game in games: + state = game.get('status', {}).get('state', '') + if state == 'in': + game_type_counts['live'] += 1 + elif state == 'post': + game_type_counts['recent'] += 1 + elif state == 'pre': + game_type_counts['upcoming'] += 1 + + # Prepare scroll content with mixed game types + # Note: Using 'mixed' as game_type indicator for scroll config + success = self._scroll_manager.prepare_and_display( + games, 'mixed', leagues, None + ) + + if success: + type_summary = ', '.join( + f"{count} {gtype}" for gtype, count in game_type_counts.items() if count > 0 + ) + self.logger.info( + f"[Lacrosse Vegas] Successfully generated scroll content: " + f"{len(games)} games ({type_summary}) from {', '.join(leagues)}" + ) + else: + self.logger.warning("[Lacrosse Vegas] Failed to generate scroll content") + + def cleanup(self) -> None: + """Clean up resources.""" + try: + if hasattr(self, "background_service") and self.background_service: + # Clean up background service if needed + pass + except Exception as e: + self.logger.error(f"Error during cleanup: {e}") diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json new file mode 100644 index 00000000..803caffc --- /dev/null +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -0,0 +1,75 @@ +{ + "id": "lacrosse-scoreboard", + "name": "Lacrosse Scoreboard", + "version": "1.0.3", + "author": "ChuckBuilds", + "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", + "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", + "entry_point": "manager.py", + "class_name": "LacrosseScoreboardPlugin", + "category": "sports", + "tags": [ + "lacrosse", + "ncaa", + "sports", + "scoreboard", + "live-scores" + ], + "icon": "fas fa-baseball-ball", + "compatible_versions": [ + ">=2.0.0" + ], + "ledmatrix_version": "2.0.0", + "requires": { + "python": ">=3.9", + "display_size": { + "min_width": 64, + "min_height": 32 + } + }, + "config_schema": "config_schema.json", + "assets": { + "logos": "Uses shared LEDMatrix assets/sports/ncaa_logos directory" + }, + "update_interval": 60, + "default_duration": 15, + "display_modes": [ + "ncaa_mens_recent", + "ncaa_mens_upcoming", + "ncaa_mens_live", + "ncaa_womens_recent", + "ncaa_womens_upcoming", + "ncaa_womens_live" + ], + "api_requirements": [ + { + "name": "ESPN API", + "required": true, + "description": "ESPN public API for NCAA lacrosse scores and schedules", + "url": "https://site.api.espn.com/apis/site/v2/sports/lacrosse/", + "rate_limit": "No official rate limit, but please use responsibly" + } + ], + "versions": [ + { + "version": "1.0.3", + "ledmatrix_min": "2.0.0", + "released": "2026-04-06" + }, + { + "version": "1.0.2", + "ledmatrix_min": "2.0.0", + "released": "2026-04-06" + }, + { + "version": "1.0.1", + "ledmatrix_min": "2.0.0", + "released": "2026-04-06" + } + ], + "last_updated": "2026-04-06", + "stars": 0, + "downloads": 0, + "verified": true, + "screenshot": "" +} diff --git a/plugins/lacrosse-scoreboard/ncaam_lacrosse_managers.py b/plugins/lacrosse-scoreboard/ncaam_lacrosse_managers.py new file mode 100644 index 00000000..d1b4eae1 --- /dev/null +++ b/plugins/lacrosse-scoreboard/ncaam_lacrosse_managers.py @@ -0,0 +1,142 @@ +import logging +from pathlib import Path +from typing import Any, Dict, Optional + +from lacrosse import Lacrosse, LacrosseLive +from sports import SportsRecent, SportsUpcoming + +# Constants +ESPN_NCAAMLAX_SCOREBOARD_URL = ( + "https://site.api.espn.com/apis/site/v2/sports/lacrosse/mens-college-lacrosse/scoreboard" +) + + +class BaseNCAAMLacrosseManager(Lacrosse): + """Base class for NCAA Men's Lacrosse managers with common functionality.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + ): + self.logger = logging.getLogger("NCAAMLAX") + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="ncaam_lacrosse", + ) + + # Check display modes to determine what data to fetch. + # Keys match the adapter output in manager.py::_adapt_config_for_manager + # and the plain "live"/"recent"/"upcoming" names used in + # config_schema.json. + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("recent", False) + self.upcoming_enabled = display_modes.get("upcoming", False) + self.live_enabled = display_modes.get("live", False) + self.league = "mens-college-lacrosse" + + self.logger.info( + f"Initialized NCAAMLacrosse manager with display dimensions: {self.display_width}x{self.display_height}" + ) + self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info( + f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}" + ) + + def _fetch_ncaa_lacrosse_api_data(self, use_cache: bool = True) -> Optional[Dict]: + """Fetch the men's NCAA lacrosse season schedule (January through May).""" + return self._fetch_season_schedule( + sport="ncaa_mens_lacrosse", + cache_key_prefix="ncaa_mens_lacrosse_schedule", + scoreboard_url=ESPN_NCAAMLAX_SCOREBOARD_URL, + season_start_mmdd="0101", + use_cache=use_cache, + ) + + def _fetch_data(self) -> Optional[Dict]: + """Default fetch: pull the cached season schedule. + + Live managers override this to query only today's games. + """ + return self._fetch_ncaa_lacrosse_api_data(use_cache=True) + + +class NCAAMLacrosseLiveManager(BaseNCAAMLacrosseManager, LacrosseLive): + """Manager for live NCAA Men's Lacrosse games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("NCAAMLacrosseLiveManager") + + # Initialize with test game only if test mode is enabled + if self.test_mode: + self.current_game = { + "id": "401712345", + "home_abbr": "MD", + "away_abbr": "JHU", + "home_score": "7", + "away_score": "5", + "period": 2, + "period_text": "Q2", + "home_id": "120", + "away_id": "228", + "clock": "08:42", + "home_logo_path": Path(self.logo_dir, "MD.png"), + "away_logo_path": Path(self.logo_dir, "JHU.png"), + "game_time": "7:00 PM", + "game_date": "Apr 12", + "is_live": True, + "is_final": False, + "is_upcoming": False, + } + self.live_games = [self.current_game] + self.logger.info( + "Initialized NCAAMLacrosseLiveManager with test game: MD vs JHU" + ) + else: + self.logger.info("Initialized NCAAMLacrosseLiveManager in live mode") + + def _fetch_data(self) -> Optional[Dict]: + """Live fetch: pull today's games directly rather than the full season.""" + return self._fetch_todays_games() + + +class NCAAMLacrosseRecentManager(BaseNCAAMLacrosseManager, SportsRecent): + """Manager for recently completed NCAA Men's Lacrosse games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("NCAAMLacrosseRecentManager") + self.logger.info( + f"Initialized NCAAMLacrosseRecentManager with {len(self.favorite_teams)} favorite teams" + ) + + +class NCAAMLacrosseUpcomingManager(BaseNCAAMLacrosseManager, SportsUpcoming): + """Manager for upcoming NCAA Men's Lacrosse games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("NCAAMLacrosseUpcomingManager") + self.logger.info( + f"Initialized NCAAMLacrosseUpcomingManager with {len(self.favorite_teams)} favorite teams" + ) diff --git a/plugins/lacrosse-scoreboard/ncaaw_lacrosse_managers.py b/plugins/lacrosse-scoreboard/ncaaw_lacrosse_managers.py new file mode 100644 index 00000000..6cd2659b --- /dev/null +++ b/plugins/lacrosse-scoreboard/ncaaw_lacrosse_managers.py @@ -0,0 +1,142 @@ +import logging +from pathlib import Path +from typing import Any, Dict, Optional + +from lacrosse import Lacrosse, LacrosseLive +from sports import SportsRecent, SportsUpcoming + +# Constants +ESPN_NCAAWLAX_SCOREBOARD_URL = ( + "https://site.api.espn.com/apis/site/v2/sports/lacrosse/womens-college-lacrosse/scoreboard" +) + + +class BaseNCAAWLacrosseManager(Lacrosse): + """Base class for NCAA Women's Lacrosse managers with common functionality.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + ): + self.logger = logging.getLogger("NCAAWLAX") + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="ncaaw_lacrosse", + ) + + # Check display modes to determine what data to fetch. + # Keys match the adapter output in manager.py::_adapt_config_for_manager + # and the plain "live"/"recent"/"upcoming" names used in + # config_schema.json. + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("recent", False) + self.upcoming_enabled = display_modes.get("upcoming", False) + self.live_enabled = display_modes.get("live", False) + self.league = "womens-college-lacrosse" + + self.logger.info( + f"Initialized NCAAWLacrosse manager with display dimensions: {self.display_width}x{self.display_height}" + ) + self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info( + f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}" + ) + + def _fetch_ncaa_lacrosse_api_data(self, use_cache: bool = True) -> Optional[Dict]: + """Fetch the women's NCAA lacrosse season schedule (February through May).""" + return self._fetch_season_schedule( + sport="ncaa_womens_lacrosse", + cache_key_prefix="ncaa_womens_lacrosse_schedule", + scoreboard_url=ESPN_NCAAWLAX_SCOREBOARD_URL, + season_start_mmdd="0201", + use_cache=use_cache, + ) + + def _fetch_data(self) -> Optional[Dict]: + """Default fetch: pull the cached season schedule. + + Live managers override this to query only today's games. + """ + return self._fetch_ncaa_lacrosse_api_data(use_cache=True) + + +class NCAAWLacrosseLiveManager(BaseNCAAWLacrosseManager, LacrosseLive): + """Manager for live NCAA Women's Lacrosse games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("NCAAWLacrosseLiveManager") + + # Initialize with test game only if test mode is enabled + if self.test_mode: + self.current_game = { + "id": "401712999", + "home_abbr": "BC", + "away_abbr": "NU", + "home_score": "11", + "away_score": "9", + "period": 3, + "period_text": "Q3", + "home_id": "103", + "away_id": "111", + "clock": "06:15", + "home_logo_path": Path(self.logo_dir, "BC.png"), + "away_logo_path": Path(self.logo_dir, "NU.png"), + "game_time": "6:00 PM", + "game_date": "Apr 13", + "is_live": True, + "is_final": False, + "is_upcoming": False, + } + self.live_games = [self.current_game] + self.logger.info( + "Initialized NCAAWLacrosseLiveManager with test game: BC vs NU" + ) + else: + self.logger.info("Initialized NCAAWLacrosseLiveManager in live mode") + + def _fetch_data(self) -> Optional[Dict]: + """Live fetch: pull today's games directly rather than the full season.""" + return self._fetch_todays_games() + + +class NCAAWLacrosseRecentManager(BaseNCAAWLacrosseManager, SportsRecent): + """Manager for recently completed NCAA Women's Lacrosse games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("NCAAWLacrosseRecentManager") + self.logger.info( + f"Initialized NCAAWLacrosseRecentManager with {len(self.favorite_teams)} favorite teams" + ) + + +class NCAAWLacrosseUpcomingManager(BaseNCAAWLacrosseManager, SportsUpcoming): + """Manager for upcoming NCAA Women's Lacrosse games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("NCAAWLacrosseUpcomingManager") + self.logger.info( + f"Initialized NCAAWLacrosseUpcomingManager with {len(self.favorite_teams)} favorite teams" + ) diff --git a/plugins/lacrosse-scoreboard/requirements.txt b/plugins/lacrosse-scoreboard/requirements.txt new file mode 100644 index 00000000..642c65fc --- /dev/null +++ b/plugins/lacrosse-scoreboard/requirements.txt @@ -0,0 +1,23 @@ +# Lacrosse Scoreboard Plugin Dependencies + +# Image processing +Pillow>=9.0.0 + +# HTTP requests for ESPN API +requests>=2.28.0 + +# Timezone handling +pytz>=2022.1 + +# URL parsing and retry logic +urllib3>=1.26.0 + +# Required LEDMatrix base classes +# These are imported from the main LEDMatrix installation: +# - src.plugin_system.base_plugin (BasePlugin) +# - src.cache_manager (CacheManager) +# - src.display_manager (DisplayManager) + +# Note: The plugin includes its own core modules (sports.py, lacrosse.py) +# to be self-contained and not depend on the main LEDMatrix installation + diff --git a/plugins/lacrosse-scoreboard/scroll_display.py b/plugins/lacrosse-scoreboard/scroll_display.py new file mode 100644 index 00000000..8aed0ffe --- /dev/null +++ b/plugins/lacrosse-scoreboard/scroll_display.py @@ -0,0 +1,651 @@ +""" +Scroll Display Handler for Lacrosse Scoreboard Plugin + +Implements high-FPS horizontal scrolling of all matching games with league separator icons. +Uses ScrollHelper for efficient numpy-based scrolling and dynamic duration calculation. + +Features: +- Pre-rendered game cards for smooth scrolling +- League separator icons (NCAA lacrosse logos) between different leagues +- Dynamic duration based on total content width +- FPS logging and performance monitoring +- Live priority support for scroll mode +- Support for mixed game types in a single scroll +""" + +import logging +import time +import os +from pathlib import Path +from typing import Dict, Any, List, Optional, Tuple +from PIL import Image + +try: + from src.common.scroll_helper import ScrollHelper +except ImportError: + ScrollHelper = None + +from game_renderer import GameRenderer + +logger = logging.getLogger(__name__) + +# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 +# Fall back to Image.LANCZOS for older versions +try: + RESAMPLE_FILTER = Image.Resampling.LANCZOS +except AttributeError: + RESAMPLE_FILTER = Image.LANCZOS + + +class ScrollDisplay: + """ + Handles scroll display mode for the lacrosse scoreboard plugin. + + This class: + - Collects all games matching criteria (respecting live priority) + - Pre-renders each game using GameRenderer + - Adds league separator icons between different leagues + - Composes a single wide image using ScrollHelper + - Implements dynamic duration based on total content width + - Logs FPS and game count during scrolling + """ + + # Paths to league separator icons. Lacrosse uses a single NCAA lacrosse + # logo for both men's and women's since ESPN does not ship separate + # gendered marks for the sport. + NCAA_SEPARATOR_ICON = "assets/sports/ncaa_logos/NCAA.png" + NCAA_LACROSSE_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaa_lacrosse.png" + + def __init__( + self, + display_manager, + config: Dict[str, Any], + custom_logger: Optional[logging.Logger] = None + ): + """ + Initialize the ScrollDisplay handler. + + Args: + display_manager: Display manager instance + config: Plugin configuration dictionary + custom_logger: Optional custom logger instance + """ + 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 + ) + # Configure scroll settings + self._configure_scroll_helper() + else: + self.scroll_helper = None + self.logger.error("ScrollHelper not available - scroll mode will not work") + + # Shared logo cache for game renderer + self._logo_cache: Dict[str, Image.Image] = {} + + # League separator icons cache + self._separator_icons: Dict[str, Image.Image] = {} + self._load_separator_icons() + + # Tracking state + self._current_games: List[Dict] = [] + self._current_game_type: str = "" + self._current_leagues: List[str] = [] + self._vegas_content_items: List[Image.Image] = [] + self._is_scrolling = False + self._scroll_start_time: Optional[float] = None + self._last_log_time: float = 0 + self._log_interval: float = 5.0 # Log every 5 seconds + + # 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 + + # Get global scroll settings, then per-league overrides + scroll_settings = self._get_scroll_settings() + + # Set scroll speed (pixels per second in time-based mode) + scroll_speed = scroll_settings.get("scroll_speed", 50.0) + self.scroll_helper.set_scroll_speed(scroll_speed) + + # Set scroll delay + scroll_delay = scroll_settings.get("scroll_delay", 0.01) + 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, # 10 minutes max + buffer=0.2 # 20% buffer to ensure scroll completes fully off screen + ) + + # Use frame-based scrolling for better FPS control + self.scroll_helper.set_frame_based_scrolling(True) + + # Convert scroll_speed from pixels/second to pixels/frame for frame-based mode + # Formula: pixels_per_frame = (pixels/second) * (seconds/frame) + if scroll_delay > 0: + pixels_per_frame = scroll_speed * scroll_delay + else: + # Fallback: assume 100 FPS if delay is 0 + pixels_per_frame = scroll_speed / 100.0 + + # Clamp to reasonable range (0.1 to 5 pixels per frame for smooth scrolling) + pixels_per_frame = max(0.1, min(5.0, pixels_per_frame)) + self.scroll_helper.set_scroll_speed(pixels_per_frame) + + # Calculate effective pixels per second for logging + 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 from {scroll_speed} px/s config), dynamic_duration={dynamic_duration}" + ) + + def _get_scroll_settings(self, league: Optional[str] = None) -> Dict[str, Any]: + """Get scroll settings, optionally for a specific league.""" + # Default scroll settings + defaults = { + "scroll_speed": 50.0, + "scroll_delay": 0.01, + "gap_between_games": 48, + "show_league_separators": True, + "dynamic_duration": True, + "game_card_width": 128, + } + + # Try to get league-specific settings first + if league: + league_config = self.config.get(league, {}) + league_scroll = league_config.get("scroll_settings", {}) + if league_scroll: + return {**defaults, **league_scroll} + + # Fall back to NCAA Men's settings (try both naming conventions) + for league_key in ["ncaa_mens", "ncaam_lacrosse"]: + ncaa_config = self.config.get(league_key, {}) + ncaa_scroll = ncaa_config.get("scroll_settings", {}) + if ncaa_scroll: + return {**defaults, **ncaa_scroll} + + # Fall back to NCAA Women's settings (try both naming conventions) + for league_key in ["ncaa_womens", "ncaaw_lacrosse"]: + ncaa_config = self.config.get(league_key, {}) + ncaa_scroll = ncaa_config.get("scroll_settings", {}) + if ncaa_scroll: + return {**defaults, **ncaa_scroll} + + return defaults + + def _load_separator_icons(self) -> None: + """Load and resize league separator icons.""" + separator_height = self.display_height - 4 # Leave some padding + + # Load NCAA icon (try sport-specific first, then generic). Both + # entries register under the lacrosse league keys so the generic + # NCAA.png acts as a real fallback when ncaa_lacrosse.png is missing — + # otherwise separator lookups for "ncaam_lacrosse" / "ncaaw_lacrosse" + # would silently return None. + lacrosse_keys = ["ncaam_lacrosse", "ncaa_mens", + "ncaaw_lacrosse", "ncaa_womens"] + ncaa_icon_paths = [ + (self.NCAA_LACROSSE_SEPARATOR_ICON, lacrosse_keys), + (self.NCAA_SEPARATOR_ICON, [*lacrosse_keys, "ncaa"]), + ] + + for icon_path, league_keys in ncaa_icon_paths: + if os.path.exists(icon_path): + try: + # Use context manager to ensure file handle is closed + with Image.open(icon_path) as ncaa_file: + # Convert creates a copy; if already RGBA, use copy() to detach from file + if ncaa_file.mode != "RGBA": + ncaa_icon = ncaa_file.convert("RGBA") + else: + ncaa_icon = ncaa_file.copy() + # Resize to fit height while maintaining aspect ratio (after file is closed) + aspect = ncaa_icon.width / ncaa_icon.height + new_width = int(separator_height * aspect) + ncaa_icon = ncaa_icon.resize((new_width, separator_height), resample=RESAMPLE_FILTER) + # Only populate keys that haven't been set yet so the + # sport-specific icon (iterated first) always wins over + # the generic NCAA fallback. + for key in league_keys: + self._separator_icons.setdefault(key, ncaa_icon) + self.logger.debug(f"Loaded NCAA separator icon from {icon_path}: {new_width}x{separator_height}") + except Exception: + self.logger.exception(f"Error loading NCAA separator icon from {icon_path}") + + def _determine_game_type(self, game: Dict) -> str: + """ + Determine the game type from the game's status. + + Args: + game: Game dictionary + + Returns: + Game type: 'live', 'recent', or 'upcoming' + """ + state = game.get('status', {}).get('state', '') + if state == 'in': + return 'live' + elif state == 'post': + return 'recent' + elif state == 'pre': + return 'upcoming' + else: + # Default to upcoming if state is unknown + return 'upcoming' + + def prepare_scroll_content( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Optional[Dict[str, int]] = None + ) -> bool: + """ + Prepare scrolling content from a list of games. + + Args: + games: List of game dictionaries with league info + game_type: Type hint ('live', 'recent', 'upcoming', or 'mixed' for mixed types) + leagues: List of leagues in order (e.g., ['ncaam_lacrosse', 'ncaaw_lacrosse']) + rankings_cache: Optional team rankings cache + + Returns: + True if content was prepared successfully, False otherwise + """ + if not self.scroll_helper: + self.logger.error("ScrollHelper not available") + return False + + if not games: + self.logger.debug("No games to prepare for scrolling") + self.clear() # Reset all scroll state, not just cache + return False + + self._current_games = games + self._current_game_type = game_type + self._current_leagues = leagues + + # Get scroll settings using primary league from the provided leagues list + primary_league = leagues[0] if leagues else None + scroll_settings = self._get_scroll_settings(primary_league) + gap_between_games = scroll_settings.get("gap_between_games", 24) + show_separators = scroll_settings.get("show_league_separators", True) + game_card_width = scroll_settings.get("game_card_width", 128) + + # Create game renderer using game_card_width so cards are a fixed size + # regardless of the full chain width (display_width may span multiple panels) + renderer = GameRenderer( + game_card_width, + self.display_height, + self.config, + logo_cache=self._logo_cache, + custom_logger=self.logger + ) + if rankings_cache: + renderer.set_rankings_cache(rankings_cache) + + # Pre-render all game cards + content_items: List[Image.Image] = [] + current_league = None + game_count = 0 + league_counts: Dict[str, int] = {} + + for game in games: + game_league = game.get("league", "ncaam_lacrosse") # Default to NCAA Men's Lacrosse if not specified + + # Add league separator if switching leagues OR if this is the first league + if show_separators: + if current_league is None: + # First league - add separator at the start + separator = self._separator_icons.get(game_league) + if separator: + # Create a separator image with proper background + sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + # Center the separator vertically + y_offset = (self.display_height - separator.height) // 2 + sep_img.paste(separator, (4, y_offset), separator) + content_items.append(sep_img) + self.logger.debug(f"Added {game_league} separator icon at start") + elif game_league != current_league: + # Switching leagues - add separator + separator = self._separator_icons.get(game_league) + if separator: + # Create a separator image with proper background + sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + # Center the separator vertically + y_offset = (self.display_height - separator.height) // 2 + sep_img.paste(separator, (4, y_offset), separator) + content_items.append(sep_img) + self.logger.debug(f"Added {game_league} separator icon") + + current_league = game_league + + # Render game card + # Only determine type from game state when in 'mixed' mode; otherwise use the passed game_type + try: + if game_type == 'mixed': + individual_game_type = self._determine_game_type(game) + else: + individual_game_type = game_type + game_img = renderer.render_game_card(game, individual_game_type) + + # Add horizontal padding to prevent logos from being cut off at edges + # Logos are positioned at -10 and display_width+10, so we need padding + padding = 12 # Padding on each side to ensure logos aren't cut off + padded_width = game_img.width + (padding * 2) + padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) + padded_img.paste(game_img, (padding, 0)) + + content_items.append(padded_img) + game_count += 1 + league_counts[game_league] = league_counts.get(game_league, 0) + 1 + except Exception: + self.logger.exception("Error rendering game card") + continue + + if not content_items: + self.logger.warning("No game cards rendered") + return False + + # Store individual items for Vegas mode (avoids scroll_helper padding) + self._vegas_content_items = list(content_items) + + # Create scrolling image using ScrollHelper + self.scroll_helper.create_scrolling_image( + content_items, + item_gap=gap_between_games, + element_gap=0 # No element gap - each item is a complete game card + ) + + # Log what we loaded + league_summary = ", ".join([f"{league.upper()}({count})" for league, count in league_counts.items()]) + self.logger.info( + f"[Lacrosse Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[Lacrosse Scroll] Total scroll width: {self.scroll_helper.total_scroll_width}px, " + f"Dynamic duration: {self.scroll_helper.calculated_duration}s" + ) + + # Reset tracking state + self._is_scrolling = True + self._scroll_start_time = time.time() + self._frame_count = 0 + self._fps_sample_start = time.time() + + return True + + def display_scroll_frame(self) -> bool: + """ + Display the next frame of the 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 + + # Display the visible portion + try: + self.display_manager.image = visible + self.display_manager.update_display() + + # Track frame rate + self._frame_count += 1 + self.scroll_helper.log_frame_rate() + + # Periodic logging + self._log_scroll_progress() + except Exception: + self.logger.exception("Error displaying scroll frame") + return False + else: + return True + + def _log_scroll_progress(self) -> None: + """Log scroll progress and FPS periodically.""" + current_time = time.time() + + if current_time - self._last_log_time >= self._log_interval: + # Calculate FPS + elapsed = current_time - self._fps_sample_start + if elapsed > 0: + fps = self._frame_count / elapsed + + # Get scroll info + scroll_info = self.scroll_helper.get_scroll_info() + + self.logger.info( + f"[Lacrosse Scroll] FPS: {fps:.1f}, " + f"Position: {scroll_info['scroll_position']:.0f}/{scroll_info['total_width']}px, " + f"Elapsed: {scroll_info.get('elapsed_time', 0):.1f}s/{scroll_info['dynamic_duration']}s" + ) + + # Reset FPS tracking + self._frame_count = 0 + self._fps_sample_start = current_time + self._last_log_time = current_time + + def is_scroll_complete(self) -> bool: + """Check if the scroll cycle is complete.""" + if not self.scroll_helper: + return True + return self.scroll_helper.is_scroll_complete() + + def reset_scroll(self) -> None: + """Reset the scroll position to the beginning.""" + if self.scroll_helper: + self.scroll_helper.reset_scroll() + self._frame_count = 0 + self._fps_sample_start = time.time() + self.logger.debug("Scroll position reset") + + def get_scroll_info(self) -> Dict[str, Any]: + """Get current scroll state information.""" + if not self.scroll_helper: + return {"error": "ScrollHelper not available"} + + info = self.scroll_helper.get_scroll_info() + info.update({ + "game_count": len(self._current_games), + "game_type": self._current_game_type, + "leagues": self._current_leagues, + "is_scrolling": self._is_scrolling + }) + return info + + def get_dynamic_duration(self) -> int: + """Get the calculated dynamic duration for this scroll content.""" + if self.scroll_helper: + return self.scroll_helper.get_dynamic_duration() + return 60 # Default fallback + + def clear(self) -> None: + """Clear scroll content and reset state.""" + if self.scroll_helper: + self.scroll_helper.clear_cache() + self._current_games = [] + self._current_game_type = "" + self._current_leagues = [] + self._vegas_content_items = [] + self._is_scrolling = False + self._scroll_start_time = None + self.logger.debug("Scroll display cleared") + + +class ScrollDisplayManager: + """ + Manages scroll display instances for different game types. + + This class provides a higher-level interface for the lacrosse plugin + to manage scroll displays for live, recent, and upcoming games. + """ + + def __init__( + self, + display_manager, + config: Dict[str, Any], + custom_logger: Optional[logging.Logger] = None + ): + """ + Initialize the ScrollDisplayManager. + + Args: + display_manager: Display manager instance + config: Plugin configuration dictionary + custom_logger: Optional custom logger instance + """ + self.display_manager = display_manager + self.config = config + self.logger = custom_logger or logger + + # Create scroll displays for each game type + self._scroll_displays: Dict[str, ScrollDisplay] = {} + self._current_game_type: Optional[str] = None + + def get_scroll_display(self, game_type: str) -> ScrollDisplay: + """ + Get or create a scroll display for a game type. + + Args: + game_type: Type of games ('live', 'recent', 'upcoming') + + Returns: + ScrollDisplay instance for the game type + """ + if game_type not in self._scroll_displays: + self._scroll_displays[game_type] = ScrollDisplay( + self.display_manager, + self.config, + self.logger + ) + return self._scroll_displays[game_type] + + def prepare_and_display( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Optional[Dict[str, int]] = None + ) -> bool: + """ + Prepare content and start displaying scroll. + + Args: + games: List of game dictionaries + game_type: Type of games + leagues: List of leagues + rankings_cache: Optional team rankings cache + + Returns: + True if scroll was started successfully + """ + scroll_display = self.get_scroll_display(game_type) + + success = scroll_display.prepare_scroll_content( + games, game_type, leagues, rankings_cache + ) + + if success: + self._current_game_type = game_type + + return success + + def display_frame(self, game_type: Optional[str] = None) -> bool: + """ + Display the next frame of the current scroll. + + Args: + game_type: Optional game type (uses current if not specified) + + Returns: + True if a frame was displayed + """ + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return False + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return False + + return scroll_display.display_scroll_frame() + + def is_complete(self, game_type: Optional[str] = None) -> bool: + """Check if the current scroll is complete.""" + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return True + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return True + + return scroll_display.is_scroll_complete() + + def get_dynamic_duration(self, game_type: Optional[str] = None) -> int: + """Get the dynamic duration for the current scroll.""" + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return 60 + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return 60 + + return scroll_display.get_dynamic_duration() + + def get_all_vegas_content_items(self) -> list: + """Collect _vegas_content_items from all scroll displays.""" + items = [] + for sd in self._scroll_displays.values(): + vegas_items = getattr(sd, '_vegas_content_items', None) + if vegas_items: + items.extend(vegas_items) + return items + + def clear_all(self) -> None: + """Clear all scroll displays.""" + for scroll_display in self._scroll_displays.values(): + scroll_display.clear() + self._current_game_type = None diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py new file mode 100644 index 00000000..bdb31936 --- /dev/null +++ b/plugins/lacrosse-scoreboard/sports.py @@ -0,0 +1,2327 @@ +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 + +# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 +# Fall back to Image.LANCZOS for older versions +try: + RESAMPLE_FILTER = Image.Resampling.LANCZOS +except AttributeError: + RESAMPLE_FILTER = Image.LANCZOS + +# Import simplified dependencies for plugin use +from dynamic_team_resolver import DynamicTeamResolver +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 + # Import from src to ensure we get the full LogoDownloader with get_logo_directory + from src.logo_downloader import LogoDownloader as MainLogoDownloader + try: + logo_downloader = MainLogoDownloader() + default_logo_dir = Path(logo_downloader.get_logo_directory(sport_key)) + self.logger.info(f"Logo directory for sport_key='{sport_key}': {default_logo_dir}") + except Exception as e: + # Fallback to default directory structure + self.logger.warning(f"Failed to get logo directory for sport_key='{sport_key}': {e}, using fallback") + default_logo_dir = Path(f"assets/sports/{sport_key}_logos") + 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 + self.show_favorite_teams_only: bool = self.mode_config.get( + "show_favorite_teams_only", False + ) + self.show_all_live: bool = self.mode_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 + self.dynamic_resolver = DynamicTeamResolver() + raw_favorite_teams = self.mode_config.get("favorite_teams", []) + self.favorite_teams = self.dynamic_resolver.resolve_teams( + raw_favorite_teams, sport_key + ) + + # 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) -> None: + """Common display method for all NCAA FB managers""" # Updated docstring + if not self.is_enabled: # Check if module is enabled + return + + 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 + + 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. + except Exception as e: + self.logger.error( + f"Error during display call in {self.__class__.__name__}: {e}", + exc_info=True, + ) + + def _load_custom_font_from_element_config( + self, + element_config: Dict[str, Any], + default_size: int = 8, + default_font: Optional[str] = None, + ) -> 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 + default_font: Default font filename when not specified in config (e.g. '4x6-font.ttf' for odds) + + Returns: + PIL ImageFont object + """ + base_default = default_font or "PressStart2P-Regular.ttf" + font_name = element_config.get("font", base_default) + 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'): + # BDF fonts are not supported by ImageFont.truetype() + # To use BDF fonts, convert to PILfont format using pilfont.py: + # python -m PIL.pilfont font.bdf + # This creates .pil and .pbm files that can be loaded with ImageFont.load() + self.logger.warning( + f"BDF font '{font_name}' not supported; convert to PILfont format " + f"using 'python -m PIL.pilfont {font_path}' then use the .pil file. " + f"Falling back to default font." + ) + # 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", base_default) + 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, default_font="4x6-font.ttf" + ) + 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), RESAMPLE_FILTER) + 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 the new architecture.""" + 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) + ) + + # Fetch odds using OddsManager + odds_data = self.odds_manager.get_odds( + sport=self.sport, + league=self.league, + event_id=game["id"], + update_interval_seconds=update_interval, + ) + + if odds_data: + game["odds"] = odds_data + self.logger.debug( + f"Successfully fetched and attached odds for game {game['id']}" + ) + else: + self.logger.debug(f"No odds data returned for game {game['id']}") + + except Exception as e: + self.logger.error( + f"Error fetching odds for game {game.get('id', 'N/A')}: {e}" + ) + + def _get_timezone(self): + timezone_name = None + + # Allow plugin-specific override first + timezone_name = self.config.get("timezone") + + if not timezone_name and self.config_manager: + try: + timezone_name = self.config_manager.get_timezone() + except Exception as exc: + self.logger.warning( + f"Error retrieving timezone from ConfigManager: {exc}" + ) + + if not timezone_name: + timezone_name = "UTC" + + try: + return pytz.timezone(timezone_name) + except pytz.UnknownTimeZoneError: + self.logger.warning( + f"Unknown timezone '{timezone_name}' - 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 {} + + 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, ESPN API typically returns times in Eastern Time for NHL/NFL + # Assume Eastern Time and convert to UTC + eastern = pytz.timezone('America/New_York') + start_time_utc = eastern.localize(dt).astimezone(pytz.UTC) + else: + # Convert to pytz.UTC for consistency + start_time_utc = dt.astimezone(pytz.UTC) + except ValueError: + self.logger.warning("Could not parse game date: %s", 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 = ( + home_team.get("records", [{}])[0].get("summary", "") + if home_team.get("records") + else "" + ) + away_record = ( + away_team.get("records", [{}])[0].get("summary", "") + if away_team.get("records") + else "" + ) + + # Don't show "0-0" records - set to blank instead + if home_record 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: + # ESPN API anchors its schedule calendar to Eastern US time. + # Always query using the Eastern date + 1-day lookback to catch + # late-night games still in progress from the previous Eastern day. + tz = pytz.timezone("America/New_York") + now = datetime.now(tz) + yesterday = now - timedelta(days=1) + formatted_date = now.strftime("%Y%m%d") + formatted_date_yesterday = yesterday.strftime("%Y%m%d") + # Fetch todays games only + url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" + response = self.session.get( + url, + params={"dates": f"{formatted_date_yesterday}-{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)} games for the last 2 days for {self.sport} - {self.league}" + ) + 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 = 15 # Display each upcoming game for 15 seconds + + 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. + """ + sorted_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.max.replace(tzinfo=timezone.utc), + ) + + if not favorite_teams: + 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 + + 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) + 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}" + ) + + 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 but no favorites configured, show all + 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 (protected by lock for thread safety) + 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 NCAA FB game.""" # Updated docstring + 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) + + 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.paste(main_img.convert("RGB"), (0, 0)) + self.display_manager.update_display() + return + + center_y = self.display_height // 2 + + # MLB-style logo positions with layout offsets + home_x = self.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 self.display_width > 128: + status_font = self.fonts["time"] + status_text = "Next Game" + status_width = draw_overlay.textlength(status_text, font=status_font) + 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') # 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 = (self.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 = (self.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"], self.display_width, self.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): + """Display upcoming games, handling switching.""" + if not self.is_enabled: + return + + 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 # 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 + + +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. + """ + 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: + 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 + + 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) + 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}" + ) + + if all(c >= self.recent_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)} recent games for {len(favorite_teams)} " + f"favorite teams: {team_counts}" + ) + 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 + game_id = game.get("id") + appears_finished = False + if not game.get("is_final", False): + clock = game.get("clock", "") + period = game.get("period", 0) + period_text = game.get("period_text", "").lower() + + if "final" in period_text: + appears_finished = True + self._clear_zero_clock_tracking(game_id) + elif period >= 4: # Lacrosse: 4 quarters (Q4 or OT) + clock_normalized = clock.replace(":", "").strip() if isinstance(clock, str) else "" + if clock_normalized in ("000", "00", "") or clock in ("0:00", ":00"): + zero_clock_duration = self._get_zero_clock_duration(game_id) + 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" + ) + else: + self._clear_zero_clock_tracking(game_id) + else: + 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) + + # 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 (protected by lock for thread safety) + 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 NCAA FB game.""" # Updated docstring + 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) + + 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.paste(main_img.convert("RGB"), (0, 0)) + self.display_manager.update_display() + return + + center_y = self.display_height // 2 + + # MLB-style logo positioning (closer to edges) with layout offsets + home_x = self.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, 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 = (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) 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 = (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"] + ) + + # 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 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, + ) + + 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() # 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): + """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 + + 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 + + +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 + try: + mode_config_keys = list(self.mode_config.keys()) if isinstance(self.mode_config, dict) else "N/A" + 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={mode_config_keys}" + ) + except Exception as e: + self.logger.warning(f"Error logging SportsLive initialization: {e}") + self.last_update = 0 + self.live_games = [] + self.current_game_index = 0 + self.last_game_switch = 0 + 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 = {} + 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. + + Lacrosse: Games end in Q4 or OT when clock hits 0:00 (period >= 4). + """ + 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"_is_game_really_over({game_str}): " + f"returning True - 'final' in period_text='{period_text}'" + ) + return True + + # Check if clock is 0:00 in P3 or OT (period >= 3) + raw_clock = game.get("clock") + period = game.get("period", 0) + + # Only check clock-based finish if we have a valid clock string + if isinstance(raw_clock, str) and raw_clock.strip() and period >= 4: + clock = raw_clock + clock_normalized = clock.replace(":", "").strip() + if clock_normalized in ("000", "00") or clock in ("0:00", ":00"): + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning True - clock at 0:00 (clock='{clock}', period={period})" + ) + return True + + self.logger.debug( + f"_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, + # no_data_interval, and update_interval are available on self. + _live_games_attr = self.live_games + _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: + # We have live games, 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 + + # Debug logging for interval selection (log every 5 minutes or when interval changes) + if current_time - self.last_log_time >= 300: # Log every 5 minutes + self.logger.info( + f"Update check: live_games={len(_live_games_attr) if _live_games_attr else 0}, " + f"update_interval={_update_interval_attr}, no_data_interval={_no_data_interval_attr}, " + f"selected_interval={interval}, time_since_last_update={time_since_last_update:.1f}s, " + f"has_recently_checked={has_recently_checked}" + ) + self.last_log_time = current_time + + # 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() + + if self.test_mode: + # Simulate clock running down in test mode + self._test_mode_update() + else: + # Fetch live game data + data = self._fetch_data() + new_live_games = [] + if data and "events" in data: + live_or_halftime_count = 0 + filtered_out_count = 0 + + for game in data["events"]: + details = self._extract_game_details(game) + if details: + # Filter out final games and games that appear to be over + if details.get("is_final", False): + continue + + if self._is_game_really_over(details): + self.logger.info( + f"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 + + if not (details["is_live"] or details["is_halftime"]): + continue + + live_or_halftime_count += 1 + + # Filtering logic matching SportsUpcoming: + # - If show_all_live = True → show all games + # - If show_favorite_teams_only = False → show all games + # - If show_favorite_teams_only = True but favorite_teams is empty → show all games (fallback) + # - If show_favorite_teams_only = True and favorite_teams has teams → only show games with those teams + if self.show_all_live: + # Always show all live games if show_all_live is enabled + should_include = True + elif not self.show_favorite_teams_only: + # If favorite teams filtering is disabled, show all games + should_include = True + 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 + else: + # Favorite teams filtering is enabled AND favorites are configured + # Only show games involving favorite teams + should_include = ( + details["home_abbr"] in self.favorite_teams + or details["away_abbr"] in self.favorite_teams + ) + + if not should_include: + filtered_out_count += 1 + self.logger.debug( + f"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: + # 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() + + 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() + + if self.show_odds: + self._fetch_odds(details) + new_live_games.append(details) + + # Detect and remove stale games from persisted list + # (new_live_games has fresh last_seen, so stale check must + # run against the previous self.live_games) + with self._games_lock: + self._detect_stale_games(self.live_games) + + # Log filtering configuration + self.logger.info( + f"Live game filtering: {len(data['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)'}" + ) + # 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 (protected by lock for thread safety) + 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: + # Sort with favorites first, then by start time + def sort_key(g): + is_favorite = self.favorite_teams and (g["home_abbr"] in self.favorite_teams or g["away_abbr"] in self.favorite_teams) + start_time = g.get("start_time_utc") or datetime.now(timezone.utc) + # Favorites first (0), non-favorites second (1), then by start time + return (0 if is_favorite else 1, start_time) + + self.live_games = sorted(new_live_games, key=sort_key) + # 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 + 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 + ) + + # 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 + + # Prune game_update_timestamps for games no longer tracked + active_ids = {g["id"] for g in self.live_games} + self.game_update_timestamps = { + gid: ts for gid, ts in self.game_update_timestamps.items() + if gid in active_ids + } + + 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 (protected by lock for thread safety) + # 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/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py b/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py new file mode 100644 index 00000000..851f3c63 --- /dev/null +++ b/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +""" +Smoke test for the Lacrosse Scoreboard plugin. + +Run standalone from the plugin directory: + + cd plugins/lacrosse-scoreboard + python test_lacrosse_plugin.py + +The test: + 1. Stubs the LEDMatrix host modules so plugin imports resolve. + 2. Imports every Python module in the plugin to catch syntax / import errors. + 3. Instantiates the dynamic team resolver and verifies the NCAA Men's Top 5 / + NCAA Women's Top 5 shortcuts resolve against the live ESPN ranking feeds. + 4. Fetches a recent window of events from both ESPN lacrosse scoreboard + endpoints and pushes each event through Lacrosse._extract_game_details, + asserting that required fields (abbreviations, IDs, scores, period, + logo URLs, records) are populated. + +No external test framework is required — the script exits non-zero on the +first failure and prints a summary on success. +""" + +from __future__ import annotations + +import json +import logging +import sys +import types +import urllib.error +import urllib.request +from datetime import datetime +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +if str(PLUGIN_DIR) not in sys.path: + sys.path.insert(0, str(PLUGIN_DIR)) + + +# --------------------------------------------------------------------------- +# Stub LEDMatrix host modules before importing the plugin. +# --------------------------------------------------------------------------- +def _install_host_stubs() -> None: + stub_modules = [ + "src", + "src.plugin_system", + "src.plugin_system.base_plugin", + "src.background_data_service", + "src.common", + "src.common.scroll_helper", + "src.api_counter", + ] + for name in stub_modules: + sys.modules.setdefault(name, types.ModuleType(name)) + sys.modules["src.plugin_system.base_plugin"].BasePlugin = object + sys.modules["src.plugin_system.base_plugin"].VegasDisplayMode = None + sys.modules["src.background_data_service"].get_background_service = ( + lambda *a, **k: None + ) + sys.modules["src.common.scroll_helper"].ScrollHelper = None + sys.modules["src.api_counter"].increment_api_counter = lambda *a, **k: None + + +_install_host_stubs() + +logging.basicConfig(level=logging.CRITICAL) + + +# --------------------------------------------------------------------------- +# Test 1: import every plugin module. +# --------------------------------------------------------------------------- +def test_imports() -> None: + import data_sources # noqa: F401 + import logo_downloader # noqa: F401 + import base_odds_manager # noqa: F401 + import dynamic_team_resolver # noqa: F401 + import game_renderer # noqa: F401 + import scroll_display # noqa: F401 + import sports # noqa: F401 + import lacrosse # noqa: F401 + import ncaam_lacrosse_managers # noqa: F401 + import ncaaw_lacrosse_managers # noqa: F401 + import manager + + assert hasattr(manager, "LacrosseScoreboardPlugin"), "plugin class missing" + print(" [ok] imports") + + +# --------------------------------------------------------------------------- +# Test 2: dynamic team resolver talks to live ESPN rankings. +# --------------------------------------------------------------------------- +def test_rankings_resolver() -> None: + from dynamic_team_resolver import DynamicTeamResolver + + resolver = DynamicTeamResolver() + men = resolver.resolve_teams(["NCAA_MENS_TOP_5"], "ncaam_lacrosse") + women = resolver.resolve_teams(["NCAA_WOMENS_TOP_5"], "ncaaw_lacrosse") + + # Treat partial outages as skips rather than failures — if we can confirm + # at least one endpoint returned real data, the resolver is working; we + # just can't fully validate the other side right now. + if not men and not women: + raise _NetworkUnavailable("both rankings endpoints returned no data") + if not men: + raise _NetworkUnavailable("men's rankings endpoint returned no data") + if not women: + raise _NetworkUnavailable("women's rankings endpoint returned no data") + + assert len(men) == 5, f"expected 5 men's teams, got {len(men)}: {men}" + assert len(women) == 5, f"expected 5 women's teams, got {len(women)}: {women}" + assert all(isinstance(t, str) and t for t in men + women), "empty team entry" + print(f" [ok] dynamic resolver — men={men[:3]}..., women={women[:3]}...") + + +class _NetworkUnavailable(Exception): + """Raised by a test when it detects the network/external feed is down.""" + + +# --------------------------------------------------------------------------- +# Test 3: extraction pipeline against live ESPN data. +# --------------------------------------------------------------------------- +REQUIRED_FIELDS = [ + "id", + "home_abbr", + "away_abbr", + "home_id", + "away_id", + "period", + "period_text", +] + + +def _fetch(url: str) -> dict: + req = urllib.request.Request(url, headers={"User-Agent": "LEDMatrix/1.0"}) + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read()) + + +def _make_test_instance(): + """Build a minimal Lacrosse instance without touching the host framework.""" + import pytz + from lacrosse import Lacrosse + from sports import SportsCore + + class _TestLacrosse(Lacrosse): + def _fetch_data(self): + return None + + inst = object.__new__(_TestLacrosse) + inst.logger = logging.getLogger("test") + inst.logger.setLevel(logging.CRITICAL) + inst.sport = "lacrosse" + inst.config = {"timezone": "America/New_York"} + inst.timezone = pytz.timezone("America/New_York") + inst.favorite_teams = [] + inst.show_favorite_teams_only = False + inst.show_records = True + inst.show_ranking = True + inst.show_odds = False + inst.logo_dir = Path("assets/sports/ncaa_logos") + inst._team_rankings_cache = {} + inst._extract_game_details_common = SportsCore._extract_game_details_common.__get__( + inst, _TestLacrosse + ) + inst._get_timezone = SportsCore._get_timezone.__get__(inst, _TestLacrosse) + return inst + + +def test_extraction(label: str, league_slug: str, date_window: str) -> None: + url = ( + f"https://site.api.espn.com/apis/site/v2/sports/lacrosse/" + f"{league_slug}/scoreboard?dates={date_window}&limit=50" + ) + data = _fetch(url) + events = data.get("events", []) + assert events, f"{label}: no events returned by ESPN for {date_window}" + + inst = _make_test_instance() + extracted = 0 + for ev in events: + details = inst._extract_game_details(ev) + if not details: + continue + extracted += 1 + missing = [f for f in REQUIRED_FIELDS if details.get(f) in (None, "")] + assert not missing, ( + f"{label}: event {ev.get('id')} missing fields {missing}: {details}" + ) + # Logo URL pattern check — ESPN NCAA CDN + for side in ("home", "away"): + logo = details.get(f"{side}_logo_url") or "" + if logo: + assert logo.startswith("https://a.espncdn.com/"), ( + f"{label}: unexpected logo host {logo}" + ) + + assert extracted == len(events), ( + f"{label}: extracted {extracted}/{len(events)} events " + f"(expected all events to parse cleanly)" + ) + print(f" [ok] {label} — {extracted}/{len(events)} events parsed cleanly") + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- +def _build_season_window() -> str: + """Build a rolling scoreboard date window for the current season. + + NCAA lacrosse runs January through late May. From January through June we + query the current calendar year; from July onward we query the upcoming + season. Returned format is 'YYYYMMDD-YYYYMMDD' as ESPN expects. + """ + now = datetime.now() + year = now.year if now.month < 7 else now.year + 1 + return f"{year}0101-{year}0601" + + +def main() -> int: + print("Lacrosse Scoreboard plugin — smoke test") + + season_window = _build_season_window() + + tests = [ + ("imports", test_imports, ()), + ("rankings resolver", test_rankings_resolver, ()), + ( + "men's extraction", + test_extraction, + ("men's", "mens-college-lacrosse", season_window), + ), + ( + "women's extraction", + test_extraction, + ("women's", "womens-college-lacrosse", season_window), + ), + ] + + # Import requests lazily so the test can still import when requests is + # unavailable — we only need it to recognise network errors. + try: + import requests.exceptions as _rexc + network_errors: tuple = (urllib.error.URLError, _rexc.RequestException) + except ImportError: + network_errors = (urllib.error.URLError,) + + failed = 0 + for name, fn, args in tests: + try: + fn(*args) + except AssertionError as e: + print(f" [FAIL] {name}: {e}") + failed += 1 + except _NetworkUnavailable as e: + print(f" [skip] {name}: {e}") + except network_errors as e: + # Network failures are non-fatal — skip with a warning so the + # test can still run on air-gapped CI. + print(f" [skip] {name}: network unavailable ({e})") + except Exception as e: # noqa: BLE001 — we want every failure surfaced + print(f" [FAIL] {name}: {type(e).__name__}: {e}") + failed += 1 + + if failed: + print(f"\n{failed} test(s) failed.") + return 1 + print("\nAll smoke tests passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())