mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
refactor: remove the skin system and the unused src/base_classes package (#615)
* refactor: remove the skin system Skins never rendered with the current scoreboard plugins: the only hook was SportsCore._render_game in src/base_classes, which no plugin builds on, so the UI and store already treated them as unsupported. The owner decided on 2026-09-23 to remove them outright. Removed src/skin_system/ (runtime, base class, fixtures), skins/, scripts/validate_skin.py and their tests; the store's "type": "skin" installer, uninstaller and hide/refuse filters (the official registry lists no skins); SchemaManager.inject_skin_selector; and GET /api/v3/skins. Stored skin/skin_options config values are handled in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): drop retired skin/skin_options keys instead of validating them A config.json written while the skin system existed can carry skin and skin_options in any plugin section, and most plugin schemas set additionalProperties: false. They are no longer core plugin properties; RETIRED_PLUGIN_KEYS in schema_manager lists them and drop_retired_plugin_keys removes them (unless the plugin's own schema declares the name) in prepare_plugin_config, which loading, hot reload, GET /plugins/config and both web saves already share, and in validate_config_against_schema for callers that validate a raw section. POST /plugins/config and /config/main also drop them from the stored section they merge into, so they leave config.json on the next save. Tests cover the load path (real PluginManager.load_plugin: no schema warning, not degraded), raw and prepared validation, validate_all_plugin_configs, and the JSON, form and /config/main saves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: remove the unused src/base_classes package No scoreboard plugin builds on src.base_classes: the nine monorepo scoreboards ship their own sports.py and share code through src/common (docs/SPORTS_UNIFICATION.md), and none of the third-party registry plugins imports it. The one import anywhere, baseball-scoreboard's rankings_manager.py, is a lazy import of ESPNDataSource in a class nothing instantiates. Removed the package and the eight test files that only tested it (test_api_extractors, test_data_sources, test_sports_base_characterization, test_sports_capabilities, test_sports_core_promotions, test_sports_logo_cache_bounded, test_sports_modes_promotions, test_sports_odds_fanout). test_common_is_hardware_free no longer lists src.base_classes as a forbidden import, and comments in sports_helpers.py and base_odds_manager.py stop pointing at it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: drop the skin system and src/base_classes from the docs Deletes docs/SKIN_SYSTEM.md and docs/CREATING_SKINS.md and every link to them (docs/README.md, README.md, PLUGIN_DEVELOPMENT_GUIDE.md, the /skins section of REST_API_REFERENCE.md), the skin section of CLAUDE.md and the term in PRODUCT.md. SPORTS_UNIFICATION.md now says src/base_classes was removed and shared code lives in src/common, in the Layering section and the view-model-contract rule. Other docs stop pointing at the removed package. CHANGELOG records both removals under Unreleased. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): hide and refuse registry entries that aren't plugins The skin filters went with the skin system, but a custom registry can still list "type": "skin" entries, and installing one as a plugin would unpack it into the plugins directory. PluginStoreManager.is_plugin_entry() (a missing type means plugin) now hides non-plugin entries from the store and custom-registry listings, and install refuses them, in the route with a clear 400 and in _install_plugin_impl for any other caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,371 +0,0 @@
|
||||
"""
|
||||
Abstract API Data Extraction Layer
|
||||
|
||||
This module provides a pluggable system for extracting game data from different
|
||||
sports APIs. Each sport can have its own extractor that handles sport-specific
|
||||
fields and data structures.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Optional
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
class APIDataExtractor(ABC):
|
||||
"""Abstract base class for API data extraction."""
|
||||
|
||||
def __init__(self, logger: logging.Logger):
|
||||
self.logger = logger
|
||||
|
||||
@abstractmethod
|
||||
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract common game details from raw API data."""
|
||||
|
||||
@abstractmethod
|
||||
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
|
||||
"""Extract sport-specific fields (downs, innings, periods, etc.)."""
|
||||
|
||||
def _extract_common_details(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]:
|
||||
"""Extract common game details that work across all sports."""
|
||||
if not game_event:
|
||||
return None, None, None, None, None
|
||||
|
||||
try:
|
||||
competition = game_event["competitions"][0]
|
||||
status = competition["status"]
|
||||
competitors = competition["competitors"]
|
||||
game_date_str = game_event["date"]
|
||||
situation = competition.get("situation")
|
||||
|
||||
# Parse game time
|
||||
start_time_utc = None
|
||||
try:
|
||||
# Parse the datetime string
|
||||
if game_date_str.endswith('Z'):
|
||||
game_date_str = game_date_str.replace('Z', '+00:00')
|
||||
dt = datetime.fromisoformat(game_date_str)
|
||||
# Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC)
|
||||
if dt.tzinfo is None:
|
||||
# If naive, assume it's UTC
|
||||
start_time_utc = dt.replace(tzinfo=pytz.UTC)
|
||||
else:
|
||||
# Convert to pytz.UTC for consistency
|
||||
start_time_utc = dt.astimezone(pytz.UTC)
|
||||
except ValueError:
|
||||
self.logger.warning(f"Could not parse game date: {game_date_str}")
|
||||
|
||||
# Extract teams
|
||||
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
|
||||
|
||||
return {
|
||||
"game_event": game_event,
|
||||
"competition": competition,
|
||||
"status": status,
|
||||
"situation": situation,
|
||||
"start_time_utc": start_time_utc,
|
||||
"home_team": home_team,
|
||||
"away_team": away_team
|
||||
}, home_team, away_team, status, situation
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error extracting common details: {e}")
|
||||
return None, None, None, None, None
|
||||
|
||||
|
||||
class ESPNFootballExtractor(APIDataExtractor):
|
||||
"""ESPN API extractor for football (NFL/NCAA)."""
|
||||
|
||||
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract football game details from ESPN API."""
|
||||
common_data, home_team, away_team, status, situation = self._extract_common_details(game_event)
|
||||
if not common_data:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Extract basic team info
|
||||
home_abbr = home_team["team"]["abbreviation"]
|
||||
away_abbr = away_team["team"]["abbreviation"]
|
||||
home_score = home_team.get("score", "0")
|
||||
away_score = away_team.get("score", "0")
|
||||
|
||||
# Extract sport-specific fields
|
||||
sport_fields = self.get_sport_specific_fields(game_event)
|
||||
|
||||
# Build game details
|
||||
details = {
|
||||
"id": game_event.get("id"),
|
||||
"home_abbr": home_abbr,
|
||||
"away_abbr": away_abbr,
|
||||
"home_score": str(home_score),
|
||||
"away_score": str(away_score),
|
||||
"home_team_name": home_team["team"].get("displayName", ""),
|
||||
"away_team_name": away_team["team"].get("displayName", ""),
|
||||
"status_text": status["type"].get("shortDetail", ""),
|
||||
"is_live": status["type"]["state"] == "in",
|
||||
"is_final": status["type"]["state"] == "post",
|
||||
"is_upcoming": status["type"]["state"] == "pre",
|
||||
**sport_fields # Add sport-specific fields
|
||||
}
|
||||
|
||||
return details
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error extracting football game details: {e}")
|
||||
return None
|
||||
|
||||
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
|
||||
"""Extract football-specific fields."""
|
||||
try:
|
||||
competition = game_event["competitions"][0]
|
||||
status = competition["status"]
|
||||
situation = competition.get("situation", {})
|
||||
|
||||
sport_fields = {
|
||||
"down": "",
|
||||
"distance": "",
|
||||
"possession": "",
|
||||
"is_redzone": False,
|
||||
"home_timeouts": 0,
|
||||
"away_timeouts": 0,
|
||||
"scoring_event": ""
|
||||
}
|
||||
|
||||
if situation and status["type"]["state"] == "in":
|
||||
sport_fields.update({
|
||||
"down": situation.get("down", ""),
|
||||
"distance": situation.get("distance", ""),
|
||||
"possession": situation.get("possession", ""),
|
||||
"is_redzone": situation.get("isRedZone", False),
|
||||
"home_timeouts": situation.get("homeTimeouts", 0),
|
||||
"away_timeouts": situation.get("awayTimeouts", 0)
|
||||
})
|
||||
|
||||
# Detect scoring events
|
||||
status_detail = status["type"].get("detail", "").lower()
|
||||
if "touchdown" in status_detail or "field goal" in status_detail:
|
||||
sport_fields["scoring_event"] = status_detail
|
||||
|
||||
return sport_fields
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error extracting football-specific fields: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
class ESPNBaseballExtractor(APIDataExtractor):
|
||||
"""ESPN API extractor for baseball (MLB)."""
|
||||
|
||||
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract baseball game details from ESPN API."""
|
||||
common_data, home_team, away_team, status, situation = self._extract_common_details(game_event)
|
||||
if not common_data:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Extract basic team info
|
||||
home_abbr = home_team["team"]["abbreviation"]
|
||||
away_abbr = away_team["team"]["abbreviation"]
|
||||
home_score = home_team.get("score", "0")
|
||||
away_score = away_team.get("score", "0")
|
||||
|
||||
# Extract sport-specific fields
|
||||
sport_fields = self.get_sport_specific_fields(game_event)
|
||||
|
||||
# Build game details
|
||||
details = {
|
||||
"id": game_event.get("id"),
|
||||
"home_abbr": home_abbr,
|
||||
"away_abbr": away_abbr,
|
||||
"home_score": str(home_score),
|
||||
"away_score": str(away_score),
|
||||
"home_team_name": home_team["team"].get("displayName", ""),
|
||||
"away_team_name": away_team["team"].get("displayName", ""),
|
||||
"status_text": status["type"].get("shortDetail", ""),
|
||||
"is_live": status["type"]["state"] == "in",
|
||||
"is_final": status["type"]["state"] == "post",
|
||||
"is_upcoming": status["type"]["state"] == "pre",
|
||||
**sport_fields # Add sport-specific fields
|
||||
}
|
||||
|
||||
return details
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error extracting baseball game details: {e}")
|
||||
return None
|
||||
|
||||
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
|
||||
"""Extract baseball-specific fields."""
|
||||
try:
|
||||
competition = game_event["competitions"][0]
|
||||
status = competition["status"]
|
||||
situation = competition.get("situation", {})
|
||||
|
||||
sport_fields = {
|
||||
"inning": "",
|
||||
"outs": 0,
|
||||
"bases": "",
|
||||
"strikes": 0,
|
||||
"balls": 0,
|
||||
"pitcher": "",
|
||||
"batter": ""
|
||||
}
|
||||
|
||||
if situation and status["type"]["state"] == "in":
|
||||
sport_fields.update({
|
||||
"inning": situation.get("inning", ""),
|
||||
"outs": situation.get("outs", 0),
|
||||
"bases": situation.get("bases", ""),
|
||||
"strikes": situation.get("strikes", 0),
|
||||
"balls": situation.get("balls", 0),
|
||||
"pitcher": situation.get("pitcher", ""),
|
||||
"batter": situation.get("batter", "")
|
||||
})
|
||||
|
||||
return sport_fields
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error extracting baseball-specific fields: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
class ESPNHockeyExtractor(APIDataExtractor):
|
||||
"""ESPN API extractor for hockey (NHL/NCAA)."""
|
||||
|
||||
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract hockey game details from ESPN API."""
|
||||
common_data, home_team, away_team, status, situation = self._extract_common_details(game_event)
|
||||
if not common_data:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Extract basic team info
|
||||
home_abbr = home_team["team"]["abbreviation"]
|
||||
away_abbr = away_team["team"]["abbreviation"]
|
||||
home_score = home_team.get("score", "0")
|
||||
away_score = away_team.get("score", "0")
|
||||
|
||||
# Extract sport-specific fields
|
||||
sport_fields = self.get_sport_specific_fields(game_event)
|
||||
|
||||
# Build game details
|
||||
details = {
|
||||
"id": game_event.get("id"),
|
||||
"home_abbr": home_abbr,
|
||||
"away_abbr": away_abbr,
|
||||
"home_score": str(home_score),
|
||||
"away_score": str(away_score),
|
||||
"home_team_name": home_team["team"].get("displayName", ""),
|
||||
"away_team_name": away_team["team"].get("displayName", ""),
|
||||
"status_text": status["type"].get("shortDetail", ""),
|
||||
"is_live": status["type"]["state"] == "in",
|
||||
"is_final": status["type"]["state"] == "post",
|
||||
"is_upcoming": status["type"]["state"] == "pre",
|
||||
**sport_fields # Add sport-specific fields
|
||||
}
|
||||
|
||||
return details
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error extracting hockey game details: {e}")
|
||||
return None
|
||||
|
||||
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
|
||||
"""Extract hockey-specific fields."""
|
||||
try:
|
||||
competition = game_event["competitions"][0]
|
||||
status = competition["status"]
|
||||
situation = competition.get("situation", {})
|
||||
|
||||
sport_fields = {
|
||||
"period": "",
|
||||
"period_text": "",
|
||||
"power_play": False,
|
||||
"penalties": "",
|
||||
"shots_on_goal": {"home": 0, "away": 0}
|
||||
}
|
||||
|
||||
if situation and status["type"]["state"] == "in":
|
||||
period = status.get("period", 0)
|
||||
period_text = ""
|
||||
if period == 1:
|
||||
period_text = "P1"
|
||||
elif period == 2:
|
||||
period_text = "P2"
|
||||
elif period == 3:
|
||||
period_text = "P3"
|
||||
elif period > 3:
|
||||
period_text = f"OT{period-3}"
|
||||
|
||||
sport_fields.update({
|
||||
"period": str(period),
|
||||
"period_text": period_text,
|
||||
"power_play": situation.get("isPowerPlay", False),
|
||||
"penalties": situation.get("penalties", ""),
|
||||
"shots_on_goal": {
|
||||
"home": situation.get("homeShots", 0),
|
||||
"away": situation.get("awayShots", 0)
|
||||
}
|
||||
})
|
||||
|
||||
return sport_fields
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error extracting hockey-specific fields: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
class SoccerAPIExtractor(APIDataExtractor):
|
||||
"""Generic extractor for soccer APIs (different structure than ESPN)."""
|
||||
|
||||
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract soccer game details from various soccer APIs."""
|
||||
# This would need to be adapted based on the specific soccer API being used
|
||||
# For now, return a basic structure
|
||||
try:
|
||||
return {
|
||||
"id": game_event.get("id"),
|
||||
"home_abbr": game_event.get("home_team", {}).get("abbreviation", ""),
|
||||
"away_abbr": game_event.get("away_team", {}).get("abbreviation", ""),
|
||||
"home_score": str(game_event.get("home_score", "0")),
|
||||
"away_score": str(game_event.get("away_score", "0")),
|
||||
"home_team_name": game_event.get("home_team", {}).get("name", ""),
|
||||
"away_team_name": game_event.get("away_team", {}).get("name", ""),
|
||||
"status_text": game_event.get("status", ""),
|
||||
"is_live": game_event.get("is_live", False),
|
||||
"is_final": game_event.get("is_final", False),
|
||||
"is_upcoming": game_event.get("is_upcoming", False),
|
||||
**self.get_sport_specific_fields(game_event)
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error extracting soccer game details: {e}")
|
||||
return None
|
||||
|
||||
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
|
||||
"""Extract soccer-specific fields."""
|
||||
try:
|
||||
return {
|
||||
"half": game_event.get("half", ""),
|
||||
"stoppage_time": game_event.get("stoppage_time", ""),
|
||||
"cards": {
|
||||
"home_yellow": game_event.get("home_yellow_cards", 0),
|
||||
"away_yellow": game_event.get("away_yellow_cards", 0),
|
||||
"home_red": game_event.get("home_red_cards", 0),
|
||||
"away_red": game_event.get("away_red_cards", 0)
|
||||
},
|
||||
"possession": {
|
||||
"home": game_event.get("home_possession", 0),
|
||||
"away": game_event.get("away_possession", 0)
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error extracting soccer-specific fields: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
# Factory function removed - sport classes now instantiate extractors directly
|
||||
@@ -1,692 +0,0 @@
|
||||
"""
|
||||
Baseball Base Classes
|
||||
|
||||
This module provides baseball-specific base classes that extend the core sports functionality
|
||||
with baseball-specific logic for innings, outs, bases, strikes, balls, etc.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from src.base_classes.data_sources import ESPNDataSource
|
||||
from src.base_classes.sports import SportsCore, SportsLive, SportsRecent
|
||||
|
||||
|
||||
class Baseball(SportsCore):
|
||||
"""Base class for baseball 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)
|
||||
# Baseball-specific configuration
|
||||
self.show_innings = self.mode_config.get("show_innings", True)
|
||||
self.show_outs = self.mode_config.get("show_outs", True)
|
||||
self.show_bases = self.mode_config.get("show_bases", True)
|
||||
self.show_count = self.mode_config.get("show_count", True)
|
||||
self.show_pitcher_batter = self.mode_config.get("show_pitcher_batter", False)
|
||||
self.show_series_summary = self.mode_config.get("show_series_summary", False)
|
||||
self.data_source = ESPNDataSource(logger)
|
||||
self.sport = "baseball"
|
||||
|
||||
def _is_baseball_game_live(self, game: Dict) -> bool:
|
||||
"""Check if a baseball game is currently live."""
|
||||
try:
|
||||
# Check if game is marked as live
|
||||
is_live = game.get("is_live", False)
|
||||
if is_live:
|
||||
return True
|
||||
|
||||
# Check inning to determine if game is active
|
||||
inning = game.get("inning", "")
|
||||
if inning and inning != "Final":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking if baseball game is live: {e}")
|
||||
return False
|
||||
|
||||
def _get_baseball_game_status(self, game: Dict) -> str:
|
||||
"""Get baseball-specific game status."""
|
||||
try:
|
||||
status = game.get("status_text", "")
|
||||
inning = game.get("inning", "")
|
||||
|
||||
if self._is_baseball_game_live(game):
|
||||
if inning:
|
||||
return f"Live - {inning}"
|
||||
else:
|
||||
return "Live"
|
||||
elif game.get("is_final", False):
|
||||
return "Final"
|
||||
elif game.get("is_upcoming", False):
|
||||
return "Upcoming"
|
||||
else:
|
||||
return status
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting baseball game status: {e}")
|
||||
return ""
|
||||
|
||||
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract relevant game details from ESPN NCAA FB 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
|
||||
try:
|
||||
# print(status["type"]["state"])
|
||||
# exit()
|
||||
game_status = status["type"]["name"].lower()
|
||||
status_state = status["type"]["state"].lower()
|
||||
# Get team abbreviations
|
||||
home_abbr = home_team["team"]["abbreviation"]
|
||||
away_abbr = away_team["team"]["abbreviation"]
|
||||
|
||||
# Check if this is a favorite team game
|
||||
is_favorite_game = (
|
||||
home_abbr in self.favorite_teams or away_abbr in self.favorite_teams
|
||||
)
|
||||
|
||||
# Log all teams found for debugging
|
||||
self.logger.debug(
|
||||
f"Found game: {away_abbr} @ {home_abbr} (Status: {game_status}, State: {status_state})"
|
||||
)
|
||||
|
||||
# Only log detailed information for favorite teams
|
||||
if is_favorite_game:
|
||||
# Use the validated competition-level `status` here too. MiLB
|
||||
# events carry no event-level one, so this debug line raised a
|
||||
# KeyError and dropped the very games it was meant to help
|
||||
# diagnose -- and only for favourites, which is the worst way
|
||||
# for it to fail.
|
||||
self.logger.debug(f"Full status data: {status}")
|
||||
self.logger.debug(f"Status type: {game_status}, State: {status_state}")
|
||||
self.logger.debug(f"Status detail: {status['type'].get('detail', '')}")
|
||||
self.logger.debug(
|
||||
f"Status shortDetail: {status['type'].get('shortDetail', '')}"
|
||||
)
|
||||
series = game_event["competitions"][0].get("series", None)
|
||||
series_summary = ""
|
||||
if series:
|
||||
series_summary = series.get("summary", "")
|
||||
# Get game state information
|
||||
if status_state == "in":
|
||||
# For live games, get detailed state
|
||||
# Use the competition-level `status` already validated by
|
||||
# _extract_game_details_common. Real ESPN events duplicate
|
||||
# status at the event top level, but MiLB events (synthesized
|
||||
# from the MLB Stats API into an ESPN-like shape) populate
|
||||
# only the competition-level one, so the top-level lookup
|
||||
# raised a bare KeyError and dropped the event.
|
||||
inning = status.get(
|
||||
"period", 1
|
||||
) # Get inning from status period
|
||||
|
||||
# Get inning information from status
|
||||
status_detail = status["type"].get("detail", "").lower()
|
||||
status_short = status["type"].get("shortDetail", "").lower()
|
||||
|
||||
if is_favorite_game:
|
||||
self.logger.debug(
|
||||
f"Raw status detail: {status['type'].get('detail')}"
|
||||
)
|
||||
self.logger.debug(
|
||||
f"Raw status short: {status['type'].get('shortDetail')}"
|
||||
)
|
||||
|
||||
# Determine inning half from status information
|
||||
inning_half = "top" # Default
|
||||
|
||||
# Handle end of inning: next inning is top
|
||||
if "end" in status_detail or "end" in status_short:
|
||||
inning_half = "top"
|
||||
inning = (
|
||||
status.get("period", 1) + 1
|
||||
) # Use period and increment for next inning
|
||||
if is_favorite_game:
|
||||
self.logger.debug(
|
||||
f"Detected end of inning. Setting to Top {inning}"
|
||||
)
|
||||
# Handle middle of inning: next is bottom of current inning
|
||||
elif "mid" in status_detail or "mid" in status_short:
|
||||
inning_half = "bottom"
|
||||
if is_favorite_game:
|
||||
self.logger.debug(
|
||||
f"Detected middle of inning. Setting to Bottom {inning}"
|
||||
)
|
||||
# Handle bottom of inning
|
||||
elif (
|
||||
"bottom" in status_detail
|
||||
or "bot" in status_detail
|
||||
or "bottom" in status_short
|
||||
or "bot" in status_short
|
||||
):
|
||||
inning_half = "bottom"
|
||||
if is_favorite_game:
|
||||
self.logger.debug(f"Detected bottom of inning: {inning}")
|
||||
# Handle top of inning
|
||||
elif "top" in status_detail or "top" in status_short:
|
||||
inning_half = "top"
|
||||
if is_favorite_game:
|
||||
self.logger.debug(f"Detected top of inning: {inning}")
|
||||
|
||||
if is_favorite_game:
|
||||
self.logger.debug(f"Status detail: {status_detail}")
|
||||
self.logger.debug(f"Status short: {status_short}")
|
||||
self.logger.debug(f"Determined inning: {inning_half} {inning}")
|
||||
|
||||
# Get count and bases from situation
|
||||
situation = game_event["competitions"][0].get("situation", {})
|
||||
|
||||
if is_favorite_game:
|
||||
self.logger.debug(f"Full situation data: {situation}")
|
||||
|
||||
# Get count from the correct location in the API response
|
||||
count = situation.get("count", {})
|
||||
balls = count.get("balls", 0)
|
||||
strikes = count.get("strikes", 0)
|
||||
outs = situation.get("outs", 0)
|
||||
|
||||
# Add detailed logging for favorite team games
|
||||
if is_favorite_game:
|
||||
self.logger.debug(f"Full situation data: {situation}")
|
||||
self.logger.debug(f"Count object: {count}")
|
||||
self.logger.debug(
|
||||
f"Raw count values - balls: {balls}, strikes: {strikes}"
|
||||
)
|
||||
self.logger.debug(f"Raw outs value: {outs}")
|
||||
|
||||
# Try alternative locations for count data
|
||||
if balls == 0 and strikes == 0:
|
||||
# First try the summary field
|
||||
if "summary" in situation:
|
||||
try:
|
||||
count_summary = situation["summary"]
|
||||
balls, strikes = map(int, count_summary.split("-"))
|
||||
if is_favorite_game:
|
||||
self.logger.debug(
|
||||
f"Using summary count: {count_summary}"
|
||||
)
|
||||
except (ValueError, AttributeError):
|
||||
if is_favorite_game:
|
||||
self.logger.debug("Could not parse summary count")
|
||||
else:
|
||||
# Check if count is directly in situation
|
||||
balls = situation.get("balls", 0)
|
||||
strikes = situation.get("strikes", 0)
|
||||
if is_favorite_game:
|
||||
self.logger.debug(
|
||||
f"Using direct situation count: balls={balls}, strikes={strikes}"
|
||||
)
|
||||
self.logger.debug(
|
||||
f"Full situation keys: {list(situation.keys())}"
|
||||
)
|
||||
|
||||
if is_favorite_game:
|
||||
self.logger.debug(f"Final count: balls={balls}, strikes={strikes}")
|
||||
|
||||
# Get base runners
|
||||
bases_occupied = [
|
||||
situation.get("onFirst", False),
|
||||
situation.get("onSecond", False),
|
||||
situation.get("onThird", False),
|
||||
]
|
||||
|
||||
if is_favorite_game:
|
||||
self.logger.debug(f"Bases occupied: {bases_occupied}")
|
||||
else:
|
||||
# Default values for non-live games
|
||||
inning = 1
|
||||
inning_half = "top"
|
||||
balls = 0
|
||||
strikes = 0
|
||||
outs = 0
|
||||
bases_occupied = [False, False, False]
|
||||
|
||||
details.update(
|
||||
{
|
||||
"status": game_status,
|
||||
"status_state": status_state,
|
||||
"inning": inning,
|
||||
"inning_half": inning_half,
|
||||
"balls": balls,
|
||||
"strikes": strikes,
|
||||
"outs": outs,
|
||||
"bases_occupied": bases_occupied,
|
||||
"start_time": game_event["date"],
|
||||
"series_summary": series_summary,
|
||||
}
|
||||
)
|
||||
|
||||
# Basic validation (can be expanded)
|
||||
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']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}"
|
||||
)
|
||||
|
||||
return details
|
||||
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
|
||||
|
||||
def display_series_summary(self, game: dict, draw_overlay: ImageDraw.ImageDraw):
|
||||
if not self.show_series_summary:
|
||||
return
|
||||
|
||||
series_summary = game.get("series_summary", "")
|
||||
bbox = draw_overlay.textbbox((0, 0), series_summary, font=self.fonts['time'])
|
||||
height = bbox[3] - bbox[1]
|
||||
shots_y = (self.display_height - height) // 2
|
||||
shots_width = draw_overlay.textlength(series_summary, font=self.fonts['time'])
|
||||
shots_x = (self.display_width - shots_width) // 2
|
||||
self._draw_text_with_outline(
|
||||
draw_overlay, series_summary, (shots_x, shots_y), self.fonts['time']
|
||||
)
|
||||
|
||||
class BaseballRecent(Baseball, SportsRecent):
|
||||
"""Base class for recent baseball games."""
|
||||
|
||||
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 _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw):
|
||||
self.display_series_summary(game, draw_overlay)
|
||||
|
||||
|
||||
class BaseballLive(Baseball, SportsLive):
|
||||
"""Base class for live baseball games."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager,
|
||||
cache_manager,
|
||||
logger: logging.Logger,
|
||||
sport_key: str,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
|
||||
def _test_mode_update(self):
|
||||
if self.current_game and self.current_game["is_live"]:
|
||||
# self.current_game["bases_occupied"] = [
|
||||
# random.choice([True, False]) for _ in range(3)
|
||||
# ]
|
||||
# self.current_game["balls"] = random.choice([1, 2, 3])
|
||||
# self.current_game["strikes"] = random.choice([1, 2])
|
||||
# self.current_game["outs"] = random.choice([1, 2])
|
||||
if self.current_game["inning_half"] == "top":
|
||||
self.current_game["inning_half"] = "bottom"
|
||||
else:
|
||||
self.current_game["inning_half"] = "top"
|
||||
self.current_game["inning"] += 1
|
||||
self.current_game["balls"] = (self.current_game["balls"] + 1) % 4
|
||||
self.current_game["strikes"] = (self.current_game["strikes"] + 1) % 3
|
||||
self.current_game["outs"] = (self.current_game["outs"] + 1) % 3
|
||||
self.current_game["bases_occupied"] = [
|
||||
not b for b in self.current_game["bases_occupied"]
|
||||
]
|
||||
if self.current_game["inning"] % 2 == 0:
|
||||
self.current_game["home_score"] = str(
|
||||
int(self.current_game["home_score"]) + 1
|
||||
)
|
||||
else:
|
||||
self.current_game["away_score"] = str(
|
||||
int(self.current_game["away_score"]) + 1
|
||||
)
|
||||
|
||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Draw the detailed scorebug layout for a live 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
|
||||
) # Draw text elements on overlay first
|
||||
|
||||
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')}"
|
||||
) # Changed log prefix
|
||||
# Draw placeholder text if logos fail
|
||||
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
|
||||
|
||||
# Draw logos (shifted slightly more inward than NHL perhaps)
|
||||
home_x = (
|
||||
self.display_width - home_logo.width + 10
|
||||
) # adjusted from 18 # Adjust position as needed
|
||||
home_y = center_y - (home_logo.height // 2)
|
||||
main_img.paste(home_logo, (home_x, home_y), home_logo)
|
||||
|
||||
away_x = -10 # adjusted from 18 # Adjust position as needed
|
||||
away_y = center_y - (away_logo.height // 2)
|
||||
main_img.paste(away_logo, (away_x, away_y), away_logo)
|
||||
|
||||
# --- Live Game Specific Elements ---
|
||||
|
||||
# Define default text color
|
||||
text_color = (255, 255, 255)
|
||||
|
||||
# Draw Inning (Top Center)
|
||||
inning_half = game["inning_half"]
|
||||
inning_num = game["inning"]
|
||||
if game["is_final"]:
|
||||
inning_text = "FINAL"
|
||||
else:
|
||||
inning_half_indicator = (
|
||||
"▲" if game["inning_half"].lower() == "top" else "▼"
|
||||
)
|
||||
inning_num = game["inning"]
|
||||
inning_text = f"{inning_half_indicator}{inning_num}"
|
||||
|
||||
inning_bbox = draw_overlay.textbbox(
|
||||
(0, 0), inning_text, font=self.display_manager.font
|
||||
)
|
||||
inning_width = inning_bbox[2] - inning_bbox[0]
|
||||
inning_x = (self.display_width - inning_width) // 2
|
||||
inning_y = 1 # Position near top center
|
||||
# draw_overlay.text((inning_x, inning_y), inning_text, fill=(255, 255, 255), font=self.display_manager.font)
|
||||
self._draw_text_with_outline(
|
||||
draw_overlay,
|
||||
inning_text,
|
||||
(inning_x, inning_y),
|
||||
self.display_manager.font,
|
||||
)
|
||||
|
||||
# --- REVISED BASES AND OUTS DRAWING ---
|
||||
bases_occupied = game["bases_occupied"] # [1st, 2nd, 3rd]
|
||||
outs = game.get("outs", 0)
|
||||
inning_half = game["inning_half"]
|
||||
|
||||
# Define geometry
|
||||
base_diamond_size = 7
|
||||
out_circle_diameter = 3
|
||||
out_vertical_spacing = 2 # Space between out circles
|
||||
spacing_between_bases_outs = (
|
||||
3 # Horizontal space between base cluster and out column
|
||||
)
|
||||
base_vert_spacing = 1 # Internal vertical space in base cluster
|
||||
base_horiz_spacing = 1 # Internal horizontal space in base cluster
|
||||
|
||||
# Calculate cluster dimensions
|
||||
base_cluster_height = (
|
||||
base_diamond_size + base_vert_spacing + base_diamond_size
|
||||
)
|
||||
base_cluster_width = (
|
||||
base_diamond_size + base_horiz_spacing + base_diamond_size
|
||||
)
|
||||
out_cluster_height = 3 * out_circle_diameter + 2 * out_vertical_spacing
|
||||
out_cluster_width = out_circle_diameter
|
||||
|
||||
# Calculate overall start positions
|
||||
overall_start_y = (
|
||||
inning_bbox[3] + 0
|
||||
) # Start immediately below inning text (moved up 3 pixels)
|
||||
|
||||
# Center the BASE cluster horizontally
|
||||
bases_origin_x = (self.display_width - base_cluster_width) // 2
|
||||
|
||||
# Determine relative positions for outs based on inning half
|
||||
if inning_half == "top": # Away batting, outs on left
|
||||
outs_column_x = (
|
||||
bases_origin_x - spacing_between_bases_outs - out_cluster_width
|
||||
)
|
||||
else: # Home batting, outs on right
|
||||
outs_column_x = (
|
||||
bases_origin_x + base_cluster_width + spacing_between_bases_outs
|
||||
)
|
||||
|
||||
# Calculate vertical alignment offset for outs column (center align with bases cluster)
|
||||
outs_column_start_y = (
|
||||
overall_start_y + (base_cluster_height // 2) - (out_cluster_height // 2)
|
||||
)
|
||||
|
||||
# --- Draw Bases (Diamonds) ---
|
||||
base_color_occupied = (255, 255, 255)
|
||||
base_color_empty = (255, 255, 255) # Outline color
|
||||
h_d = base_diamond_size // 2
|
||||
|
||||
# 2nd Base (Top center relative to bases_origin_x)
|
||||
c2x = bases_origin_x + base_cluster_width // 2
|
||||
c2y = overall_start_y + h_d
|
||||
poly2 = [
|
||||
(c2x, overall_start_y),
|
||||
(c2x + h_d, c2y),
|
||||
(c2x, c2y + h_d),
|
||||
(c2x - h_d, c2y),
|
||||
]
|
||||
if bases_occupied[1]:
|
||||
draw_overlay.polygon(poly2, fill=base_color_occupied)
|
||||
else:
|
||||
draw_overlay.polygon(poly2, outline=base_color_empty)
|
||||
|
||||
base_bottom_y = c2y + h_d # Bottom Y of 2nd base diamond
|
||||
|
||||
# 3rd Base (Bottom left relative to bases_origin_x)
|
||||
c3x = bases_origin_x + h_d
|
||||
c3y = base_bottom_y + base_vert_spacing + h_d
|
||||
poly3 = [
|
||||
(c3x, base_bottom_y + base_vert_spacing),
|
||||
(c3x + h_d, c3y),
|
||||
(c3x, c3y + h_d),
|
||||
(c3x - h_d, c3y),
|
||||
]
|
||||
if bases_occupied[2]:
|
||||
draw_overlay.polygon(poly3, fill=base_color_occupied)
|
||||
else:
|
||||
draw_overlay.polygon(poly3, outline=base_color_empty)
|
||||
|
||||
# 1st Base (Bottom right relative to bases_origin_x)
|
||||
c1x = bases_origin_x + base_cluster_width - h_d
|
||||
c1y = base_bottom_y + base_vert_spacing + h_d
|
||||
poly1 = [
|
||||
(c1x, base_bottom_y + base_vert_spacing),
|
||||
(c1x + h_d, c1y),
|
||||
(c1x, c1y + h_d),
|
||||
(c1x - h_d, c1y),
|
||||
]
|
||||
if bases_occupied[0]:
|
||||
draw_overlay.polygon(poly1, fill=base_color_occupied)
|
||||
else:
|
||||
draw_overlay.polygon(poly1, outline=base_color_empty)
|
||||
|
||||
# --- Draw Outs (Vertical Circles) ---
|
||||
circle_color_out = (255, 255, 255)
|
||||
circle_color_empty_outline = (100, 100, 100)
|
||||
|
||||
for i in range(3):
|
||||
cx = outs_column_x
|
||||
cy = outs_column_start_y + i * (
|
||||
out_circle_diameter + out_vertical_spacing
|
||||
)
|
||||
coords = [cx, cy, cx + out_circle_diameter, cy + out_circle_diameter]
|
||||
if i < outs:
|
||||
draw_overlay.ellipse(coords, fill=circle_color_out)
|
||||
else:
|
||||
draw_overlay.ellipse(coords, outline=circle_color_empty_outline)
|
||||
|
||||
# --- Draw Balls-Strikes Count (BDF Font) ---
|
||||
balls = game.get("balls", 0)
|
||||
strikes = game.get("strikes", 0)
|
||||
|
||||
# Add debug logging for count with cooldown
|
||||
current_time = time.time()
|
||||
if (
|
||||
game["home_abbr"] in self.favorite_teams
|
||||
or game["away_abbr"] in self.favorite_teams
|
||||
) and current_time - self.last_count_log_time >= self.count_log_interval:
|
||||
self.logger.debug(f"Displaying count: {balls}-{strikes}")
|
||||
self.logger.debug(
|
||||
f"Raw count data: balls={game.get('balls')}, strikes={game.get('strikes')}"
|
||||
)
|
||||
self.last_count_log_time = current_time
|
||||
|
||||
count_text = f"{balls}-{strikes}"
|
||||
bdf_font = self.display_manager.calendar_font
|
||||
bdf_font.set_char_size(height=7 * 64) # Set 7px height
|
||||
count_text_width = self.display_manager.get_text_width(count_text, bdf_font)
|
||||
|
||||
# Position below the base/out cluster
|
||||
cluster_bottom_y = (
|
||||
overall_start_y + base_cluster_height
|
||||
) # Find the bottom of the taller part (bases)
|
||||
count_y = cluster_bottom_y + 2 # Start 2 pixels below cluster
|
||||
|
||||
# Center horizontally within the BASE cluster width
|
||||
count_x = bases_origin_x + (base_cluster_width - count_text_width) // 2
|
||||
|
||||
# Ensure draw object is set and draw text
|
||||
self.display_manager.draw = draw_overlay
|
||||
# self.display_manager._draw_bdf_text(count_text, count_x, count_y, text_color, font=bdf_font)
|
||||
# Use _draw_text_with_outline for count text
|
||||
# self._draw_text_with_outline(draw, count_text, (count_x, count_y), bdf_font, fill=text_color)
|
||||
|
||||
# Draw Balls-Strikes Count with outline using BDF font
|
||||
# Define outline color (consistent with _draw_text_with_outline default)
|
||||
outline_color_for_bdf = (0, 0, 0)
|
||||
|
||||
# Draw outline
|
||||
for dx_offset, dy_offset in [
|
||||
(-1, -1),
|
||||
(-1, 0),
|
||||
(-1, 1),
|
||||
(0, -1),
|
||||
(0, 1),
|
||||
(1, -1),
|
||||
(1, 0),
|
||||
(1, 1),
|
||||
]:
|
||||
self.display_manager._draw_bdf_text(
|
||||
count_text,
|
||||
count_x + dx_offset,
|
||||
count_y + dy_offset,
|
||||
color=outline_color_for_bdf,
|
||||
font=bdf_font,
|
||||
)
|
||||
|
||||
# Draw main text
|
||||
self.display_manager._draw_bdf_text(
|
||||
count_text, count_x, count_y, color=text_color, font=bdf_font
|
||||
)
|
||||
|
||||
# Draw Team:Score at the bottom (matching main branch format)
|
||||
score_font = self.display_manager.font # Use PressStart2P
|
||||
outline_color = (0, 0, 0)
|
||||
score_text_color = (
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
) # Use a specific name for score text color
|
||||
|
||||
# Helper function for outlined text
|
||||
def draw_bottom_outlined_text(x, y, text):
|
||||
self._draw_text_with_outline(
|
||||
draw_overlay,
|
||||
text,
|
||||
(x, y),
|
||||
score_font,
|
||||
fill=score_text_color,
|
||||
outline_color=outline_color,
|
||||
)
|
||||
|
||||
away_abbr = game["away_abbr"]
|
||||
home_abbr = game["home_abbr"]
|
||||
away_score_str = str(game["away_score"])
|
||||
home_score_str = str(game["home_score"])
|
||||
|
||||
away_text = f"{away_abbr}:{away_score_str}"
|
||||
home_text = f"{home_abbr}:{home_score_str}"
|
||||
|
||||
# Calculate Y position (bottom edge)
|
||||
# Get font height (approximate or precise)
|
||||
try:
|
||||
font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1]
|
||||
except AttributeError:
|
||||
font_height = 8 # Fallback for default font
|
||||
score_y = (
|
||||
self.display_height - font_height - 2
|
||||
) # 2 pixels padding from bottom
|
||||
|
||||
# Away Team:Score (Bottom Left)
|
||||
away_score_x = 2 # 2 pixels padding from left
|
||||
draw_bottom_outlined_text(away_score_x, score_y, away_text)
|
||||
|
||||
# Home Team:Score (Bottom Right)
|
||||
home_text_bbox = draw_overlay.textbbox((0, 0), home_text, font=score_font)
|
||||
home_text_width = home_text_bbox[2] - home_text_bbox[0]
|
||||
home_score_x = (
|
||||
self.display_width - home_text_width - 2
|
||||
) # 2 pixels padding from right
|
||||
draw_bottom_outlined_text(home_score_x, score_y, home_text)
|
||||
|
||||
# Draw gambling odds if available
|
||||
if "odds" in game and game["odds"]:
|
||||
self._draw_dynamic_odds(
|
||||
draw_overlay, game["odds"], self.display_width, self.display_height
|
||||
)
|
||||
|
||||
# Composite the text overlay onto the main image
|
||||
main_img = Image.alpha_composite(main_img, overlay)
|
||||
main_img = main_img.convert("RGB") # Convert for display
|
||||
|
||||
# Display the final image
|
||||
self.display_manager.image.paste(main_img, (0, 0))
|
||||
self.display_manager.update_display() # Update display here for live
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error displaying live Baseball game: {e}", exc_info=True
|
||||
) # Changed log prefix
|
||||
@@ -1,300 +0,0 @@
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from src.base_classes.data_sources import ESPNDataSource
|
||||
from src.base_classes.sports import SportsCore, SportsLive
|
||||
from src.cache_manager import CacheManager
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
|
||||
class Basketball(SportsCore):
|
||||
"""Base class for basketball sports with common functionality."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
logger: logging.Logger,
|
||||
sport_key: str,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
self.data_source = ESPNDataSource(logger)
|
||||
self.sport = "basketball"
|
||||
|
||||
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract relevant game details from ESPN NCAA FB API response."""
|
||||
# --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES ---
|
||||
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
|
||||
try:
|
||||
# Format period/quarter
|
||||
period = status.get("period", 0)
|
||||
period_text = ""
|
||||
if status["type"]["state"] == "in":
|
||||
if period == 0:
|
||||
period_text = "Start" # Before kickoff
|
||||
elif period >= 1 and period <= 4:
|
||||
period_text = f"Q{period}" # OT starts after Q4
|
||||
elif period > 4:
|
||||
period_text = f"OT{period - 4}" # OT starts after Q4
|
||||
elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state
|
||||
period_text = "HALF"
|
||||
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", "") # Show time for upcoming
|
||||
|
||||
details.update({
|
||||
"period": period,
|
||||
"period_text": period_text, # Formatted quarter/status
|
||||
"clock": status.get("displayClock", "0:00"),
|
||||
})
|
||||
|
||||
# Basic validation (can be expanded)
|
||||
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']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}"
|
||||
)
|
||||
|
||||
return details
|
||||
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
|
||||
|
||||
|
||||
class BasketballLive(Basketball, SportsLive):
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
logger: logging.Logger,
|
||||
sport_key: str,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
|
||||
def _test_mode_update(self):
|
||||
if self.current_game and self.current_game["is_live"]:
|
||||
# For testing, we'll just update the clock to show it's working
|
||||
minutes = int(self.current_game["clock"].split(":")[0])
|
||||
seconds = int(self.current_game["clock"].split(":")[1])
|
||||
seconds -= 1
|
||||
if seconds < 0:
|
||||
seconds = 59
|
||||
minutes -= 1
|
||||
if minutes < 0:
|
||||
minutes = 19
|
||||
if self.current_game["period"] < 3:
|
||||
self.current_game["period"] += 1
|
||||
else:
|
||||
self.current_game["period"] = 1
|
||||
self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}"
|
||||
# Always update display in test mode
|
||||
|
||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Draw the detailed scorebug layout for a live Basketball 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
|
||||
) # Draw text elements on overlay first
|
||||
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')}"
|
||||
) # Changed log prefix
|
||||
# Draw placeholder text if logos fail
|
||||
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
|
||||
|
||||
# Draw logos (shifted slightly more inward than NHL perhaps)
|
||||
home_x = (
|
||||
self.display_width - home_logo.width + 10
|
||||
) # adjusted from 18 # Adjust position as needed
|
||||
home_y = center_y - (home_logo.height // 2)
|
||||
main_img.paste(home_logo, (home_x, home_y), home_logo)
|
||||
|
||||
away_x = -10 # adjusted from 18 # Adjust position as needed
|
||||
away_y = center_y - (away_logo.height // 2)
|
||||
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
|
||||
|
||||
# Period/Quarter and Clock (Top center)
|
||||
period_clock_text = (
|
||||
f"{game.get('period_text', '')} {game.get('clock', '')}".strip()
|
||||
)
|
||||
|
||||
status_width = draw_overlay.textlength(
|
||||
period_clock_text, font=self.fonts["time"]
|
||||
)
|
||||
status_x = (self.display_width - status_width) // 2
|
||||
status_y = 1 # Position at top
|
||||
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
|
||||
score_y = (
|
||||
self.display_height // 2
|
||||
) - 3 # centered #from 14 # Position score higher
|
||||
self._draw_text_with_outline(
|
||||
draw_overlay, score_text, (score_x, score_y), self.fonts["score"]
|
||||
)
|
||||
|
||||
# Draw odds if available
|
||||
if "odds" in game and game["odds"]:
|
||||
self._draw_dynamic_odds(
|
||||
draw_overlay, game["odds"], self.display_width, self.display_height
|
||||
)
|
||||
|
||||
# Draw records or rankings if enabled
|
||||
if self.show_records or self.show_ranking:
|
||||
record_font = self.fonts.get('detail', ImageFont.load_default())
|
||||
|
||||
# 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 - 1
|
||||
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 = 3
|
||||
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 - 3
|
||||
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 the text overlay onto the main image
|
||||
main_img = Image.alpha_composite(main_img, overlay)
|
||||
main_img = main_img.convert("RGB") # Convert for display
|
||||
|
||||
# Display the final image
|
||||
self.display_manager.image.paste(main_img, (0, 0))
|
||||
self.display_manager.update_display() # Update display here for live
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error displaying live Hockey game: {e}", exc_info=True
|
||||
) # Changed log prefix
|
||||
@@ -1,344 +0,0 @@
|
||||
"""
|
||||
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, List
|
||||
import requests
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from src.common.espn_dates import fetch_espn_scoreboard
|
||||
|
||||
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."""
|
||||
|
||||
@abstractmethod
|
||||
def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
|
||||
"""Fetch schedule for a sport/league within date range."""
|
||||
|
||||
@abstractmethod
|
||||
def fetch_standings(self, sport: str, league: str) -> Dict:
|
||||
"""Fetch standings for a sport/league."""
|
||||
|
||||
def get_headers(self) -> Dict[str, str]:
|
||||
"""Get headers for API requests.
|
||||
|
||||
The agent carries the project URL deliberately. Around 2026-08-04 ESPN
|
||||
began returning 403 for bare custom tokens like 'LEDMatrix/1.0' — and
|
||||
for browser-style strings — while accepting an agent that identifies
|
||||
the client and links to it. An Accept header alone does not rescue the
|
||||
bare form when the request goes out through requests.
|
||||
"""
|
||||
return {
|
||||
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
|
||||
'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"
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session, url, params={"dates": formatted_date, "limit": 1000},
|
||||
headers=self.get_headers(), timeout=15, logger=self.logger,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session, url, params=params,
|
||||
headers=self.get_headers(), timeout=15, logger=self.logger,
|
||||
)
|
||||
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, or the poll for leagues that have one.
|
||||
|
||||
Order matters and used to be wrong. College leagues publish a poll at
|
||||
/rankings and a records table at /standings; professional leagues have
|
||||
only /standings. The old code tried /standings first and fell back to
|
||||
/rankings only on a 404 -- but college /standings answers 200, so the
|
||||
fallback never fired and college rankings came back empty forever.
|
||||
Nothing failed; the AP rank badge simply never appeared, and anything
|
||||
else keyed off rankings quietly did nothing.
|
||||
|
||||
A 200 that lacks the key is treated as a miss, so a league answering
|
||||
both endpoints still ends up with whichever one actually carries a poll.
|
||||
"""
|
||||
league_name = (league or "").lower()
|
||||
wants_poll = "college" in league_name or "ncaa" in league_name
|
||||
endpoints = ["rankings", "standings"] if wants_poll else ["standings", "rankings"]
|
||||
|
||||
for endpoint in endpoints:
|
||||
url = f"{self.base_url}/{sport}/{league}/{endpoint}"
|
||||
# Only the request is guarded. Inspecting the payload happens
|
||||
# below, outside the handler, so that a bug in this method cannot
|
||||
# be mistaken for an endpoint that failed -- that mistake would
|
||||
# silently drop rankings for a league that has them, which is the
|
||||
# exact failure this function was written to fix.
|
||||
try:
|
||||
response = self.session.get(
|
||||
url, headers=self.get_headers(), timeout=15
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||
# Only a 404 is routine -- it is how a league says "no poll
|
||||
# here". Everything else is worth an error, and `status is
|
||||
# None` covers the ones that matter most: ConnectionError,
|
||||
# Timeout, a body that would not parse. Silencing those left a
|
||||
# board that could not reach ESPN with one debug line, and the
|
||||
# ranked filter running on an empty table.
|
||||
if status != 404:
|
||||
self.logger.error(
|
||||
f"Error fetching {endpoint} from ESPN for "
|
||||
f"{sport}/{league}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
if not isinstance(data, dict):
|
||||
# A list or a bare string is not something the callers can
|
||||
# read. Treat it as a miss so the other endpoint still gets a
|
||||
# turn, but say so -- this means ESPN changed shape.
|
||||
self.logger.error(
|
||||
f"Unexpected {endpoint} payload for {sport}/{league}: "
|
||||
f"got {type(data).__name__}, expected an object"
|
||||
)
|
||||
continue
|
||||
if endpoint == "rankings" and not data.get("rankings"):
|
||||
continue
|
||||
self.logger.debug(f"Fetched {endpoint} for {sport}/{league}")
|
||||
return data
|
||||
self.logger.debug(
|
||||
f"Standings/rankings not available for {sport}/{league} from ESPN API"
|
||||
)
|
||||
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("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("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
|
||||
@@ -1,387 +0,0 @@
|
||||
from typing import Dict, Any, Optional
|
||||
from src.display_manager import DisplayManager
|
||||
from src.cache_manager import CacheManager
|
||||
import logging
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.base_classes.data_sources import ESPNDataSource
|
||||
from src.base_classes.sports import SportsCore, SportsLive
|
||||
|
||||
class Football(SportsCore):
|
||||
"""Base class for football sports with common functionality."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
self.data_source = ESPNDataSource(logger)
|
||||
self.sport = "football"
|
||||
|
||||
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract relevant game details from ESPN NCAA FB 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
|
||||
try:
|
||||
competition = game_event["competitions"][0]
|
||||
status = competition["status"]
|
||||
|
||||
# --- Football Specific Details (Likely same for NFL/NCAAFB) ---
|
||||
down_distance_text = ""
|
||||
down_distance_text_long = ""
|
||||
possession_indicator = None # Default to None
|
||||
scoring_event = "" # Track scoring events
|
||||
home_timeouts = 0
|
||||
away_timeouts = 0
|
||||
is_redzone = False
|
||||
posession = None
|
||||
|
||||
if situation and status["type"]["state"] == "in":
|
||||
# down = situation.get("down")
|
||||
down_distance_text = situation.get("shortDownDistanceText")
|
||||
down_distance_text_long = situation.get("downDistanceText")
|
||||
# distance = situation.get("distance")
|
||||
|
||||
# Detect scoring events from status detail
|
||||
status_detail = status["type"].get("detail", "").lower()
|
||||
status_short = status["type"].get("shortDetail", "").lower()
|
||||
is_redzone = situation.get("isRedZone")
|
||||
posession = situation.get("possession")
|
||||
|
||||
# Check for scoring events in status text
|
||||
if any(keyword in status_detail for keyword in ["touchdown", "td"]):
|
||||
scoring_event = "TOUCHDOWN"
|
||||
elif any(keyword in status_detail for keyword in ["field goal", "fg"]):
|
||||
scoring_event = "FIELD GOAL"
|
||||
elif any(keyword in status_detail for keyword in ["extra point", "pat", "point after"]):
|
||||
scoring_event = "PAT"
|
||||
elif any(keyword in status_short for keyword in ["touchdown", "td"]):
|
||||
scoring_event = "TOUCHDOWN"
|
||||
elif any(keyword in status_short for keyword in ["field goal", "fg"]):
|
||||
scoring_event = "FIELD GOAL"
|
||||
elif any(keyword in status_short for keyword in ["extra point", "pat"]):
|
||||
scoring_event = "PAT"
|
||||
|
||||
# Determine possession based on team ID
|
||||
possession_team_id = situation.get("possession")
|
||||
if possession_team_id:
|
||||
if possession_team_id == home_team.get("id"):
|
||||
possession_indicator = "home"
|
||||
elif possession_team_id == away_team.get("id"):
|
||||
possession_indicator = "away"
|
||||
|
||||
home_timeouts = situation.get("homeTimeouts", 3) # Default to 3 if not specified
|
||||
away_timeouts = situation.get("awayTimeouts", 3) # Default to 3 if not specified
|
||||
|
||||
|
||||
# Format period/quarter
|
||||
period = status.get("period", 0)
|
||||
period_text = ""
|
||||
if status["type"]["state"] == "in":
|
||||
if period == 0:
|
||||
period_text = "Start" # Before kickoff
|
||||
elif period >= 1 and period <= 4:
|
||||
period_text = f"Q{period}" # OT starts after Q4
|
||||
elif period > 4:
|
||||
period_text = f"OT{period - 4}" # OT starts after Q4
|
||||
elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state
|
||||
period_text = "HALF"
|
||||
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", "") # Show time for upcoming
|
||||
|
||||
details.update({
|
||||
"period": period,
|
||||
"period_text": period_text, # Formatted quarter/status
|
||||
"clock": status.get("displayClock", "0:00"),
|
||||
"home_timeouts": home_timeouts,
|
||||
"away_timeouts": away_timeouts,
|
||||
"down_distance_text": down_distance_text, # Added Down/Distance
|
||||
"down_distance_text_long": down_distance_text_long,
|
||||
"is_redzone": is_redzone,
|
||||
"possession": posession, # ID of team with possession
|
||||
"possession_indicator": possession_indicator, # Added for easy home/away check
|
||||
"scoring_event": scoring_event, # Track scoring events (TOUCHDOWN, FIELD GOAL, PAT)
|
||||
})
|
||||
|
||||
# Basic validation (can be expanded)
|
||||
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']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}")
|
||||
|
||||
return details
|
||||
except Exception as e:
|
||||
# Log the problematic event structure if possible
|
||||
logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True)
|
||||
return None
|
||||
|
||||
class FootballLive(Football, SportsLive):
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
|
||||
def _test_mode_update(self):
|
||||
if self.current_game and self.current_game["is_live"]:
|
||||
try:
|
||||
minutes, seconds = map(int, self.current_game["clock"].split(':'))
|
||||
seconds -= 1
|
||||
if seconds < 0:
|
||||
seconds = 59
|
||||
minutes -= 1
|
||||
if minutes < 0:
|
||||
# Simulate end of quarter/game
|
||||
if self.current_game["period"] < 4: # Q4 is period 4
|
||||
self.current_game["period"] += 1
|
||||
# Update period_text based on new period
|
||||
if self.current_game["period"] == 1: self.current_game["period_text"] = "Q1"
|
||||
elif self.current_game["period"] == 2: self.current_game["period_text"] = "Q2"
|
||||
elif self.current_game["period"] == 3: self.current_game["period_text"] = "Q3"
|
||||
elif self.current_game["period"] == 4: self.current_game["period_text"] = "Q4"
|
||||
# Reset clock for next quarter (e.g., 15:00)
|
||||
minutes, seconds = 15, 0
|
||||
else:
|
||||
# Simulate game end
|
||||
self.current_game["is_live"] = False
|
||||
self.current_game["is_final"] = True
|
||||
self.current_game["period_text"] = "Final"
|
||||
minutes, seconds = 0, 0
|
||||
self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}"
|
||||
# Simulate down change occasionally
|
||||
if seconds % 15 == 0:
|
||||
self.current_game["down_distance_text"] = f"{['1st','2nd','3rd','4th'][seconds % 4]} & {seconds % 10 + 1}"
|
||||
self.current_game["status_text"] = f"{self.current_game['period_text']} {self.current_game['clock']}"
|
||||
|
||||
# Display update handled by main loop or explicit call if needed immediately
|
||||
# self.display(force_clear=True) # Only if immediate update is desired here
|
||||
|
||||
except ValueError:
|
||||
self.logger.warning("Test mode: Could not parse clock") # Changed log prefix
|
||||
# No actual display call here, let main loop handle it
|
||||
|
||||
|
||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Draw the detailed scorebug layout for a live 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) # Draw text elements on overlay first
|
||||
|
||||
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')}") # Changed log prefix
|
||||
# Draw placeholder text if logos fail
|
||||
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
|
||||
|
||||
# Draw logos (shifted slightly more inward than NHL perhaps)
|
||||
home_x = self.display_width - home_logo.width + 10 #adjusted from 18 # Adjust position as needed
|
||||
home_y = center_y - (home_logo.height // 2)
|
||||
main_img.paste(home_logo, (home_x, home_y), home_logo)
|
||||
|
||||
away_x = -10 #adjusted from 18 # Adjust position as needed
|
||||
away_y = center_y - (away_logo.height // 2)
|
||||
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
|
||||
|
||||
# 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
|
||||
score_y = (self.display_height // 2) - 3 #centered #from 14 # Position score higher
|
||||
self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score'])
|
||||
|
||||
# Period/Quarter and Clock (Top center)
|
||||
period_clock_text = f"{game.get('period_text', '')} {game.get('clock', '')}".strip()
|
||||
if game.get("is_halftime"): \
|
||||
period_clock_text = "Halftime" # Override for halftime
|
||||
elif game.get("is_period_break"):
|
||||
period_clock_text = game.get("status_text", "Period Break")
|
||||
|
||||
status_width = draw_overlay.textlength(period_clock_text, font=self.fonts['time'])
|
||||
status_x = (self.display_width - status_width) // 2
|
||||
status_y = 1 # Position at top
|
||||
self._draw_text_with_outline(draw_overlay, period_clock_text, (status_x, status_y), self.fonts['time'])
|
||||
|
||||
# Down & Distance or Scoring Event (Below Period/Clock)
|
||||
scoring_event = game.get("scoring_event", "")
|
||||
down_distance = game.get("down_distance_text", "")
|
||||
if self.display_width > 128:
|
||||
down_distance = game.get("down_distance_text_long", "")
|
||||
|
||||
# Show scoring event if detected, otherwise show down & distance
|
||||
if scoring_event and game.get("is_live"):
|
||||
# Display scoring event with special formatting
|
||||
event_width = draw_overlay.textlength(scoring_event, font=self.fonts['detail'])
|
||||
event_x = (self.display_width - event_width) // 2
|
||||
event_y = (self.display_height) - 7
|
||||
|
||||
# Color coding for different scoring events
|
||||
if scoring_event == "TOUCHDOWN":
|
||||
event_color = (255, 215, 0) # Gold
|
||||
elif scoring_event == "FIELD GOAL":
|
||||
event_color = (0, 255, 0) # Green
|
||||
elif scoring_event == "PAT":
|
||||
event_color = (255, 165, 0) # Orange
|
||||
else:
|
||||
event_color = (255, 255, 255) # White
|
||||
|
||||
self._draw_text_with_outline(draw_overlay, scoring_event, (event_x, event_y), self.fonts['detail'], fill=event_color)
|
||||
elif down_distance and game.get("is_live"): # Only show if live and available
|
||||
dd_width = draw_overlay.textlength(down_distance, font=self.fonts['detail'])
|
||||
dd_x = (self.display_width - dd_width) // 2
|
||||
dd_y = (self.display_height)- 7 # Top of D&D text
|
||||
down_color = (200, 200, 0) if not game.get("is_redzone", False) else (255,0,0) # Yellowish text
|
||||
self._draw_text_with_outline(draw_overlay, down_distance, (dd_x, dd_y), self.fonts['detail'], fill=down_color)
|
||||
|
||||
# Possession Indicator (small football icon)
|
||||
possession = game.get("possession_indicator")
|
||||
if possession: # Only draw if possession is known
|
||||
ball_radius_x = 3 # Wider for football shape
|
||||
ball_radius_y = 2 # Shorter for football shape
|
||||
ball_color = (139, 69, 19) # Brown color for the football
|
||||
lace_color = (255, 255, 255) # White for laces
|
||||
|
||||
# Approximate height of the detail font (4x6 font at size 6 is roughly 6px tall)
|
||||
detail_font_height_approx = 6
|
||||
ball_y_center = dd_y + (detail_font_height_approx // 2) # Center ball vertically with D&D text
|
||||
|
||||
possession_ball_padding = 3 # Pixels between D&D text and ball
|
||||
|
||||
if possession == "away":
|
||||
# Position ball to the left of D&D text
|
||||
ball_x_center = dd_x - possession_ball_padding - ball_radius_x
|
||||
elif possession == "home":
|
||||
# Position ball to the right of D&D text
|
||||
ball_x_center = dd_x + dd_width + possession_ball_padding + ball_radius_x
|
||||
else:
|
||||
ball_x_center = 0 # Should not happen / no indicator
|
||||
|
||||
if ball_x_center > 0: # Draw if position is valid
|
||||
# Draw the football shape (ellipse)
|
||||
draw_overlay.ellipse(
|
||||
(ball_x_center - ball_radius_x, ball_y_center - ball_radius_y, # x0, y0
|
||||
ball_x_center + ball_radius_x, ball_y_center + ball_radius_y), # x1, y1
|
||||
fill=ball_color, outline=(0,0,0)
|
||||
)
|
||||
# Draw a simple horizontal lace
|
||||
draw_overlay.line(
|
||||
(ball_x_center - 1, ball_y_center, ball_x_center + 1, ball_y_center),
|
||||
fill=lace_color, width=1
|
||||
)
|
||||
|
||||
# Timeouts (Bottom corners) - 3 small bars per team
|
||||
timeout_bar_width = 4
|
||||
timeout_bar_height = 2
|
||||
timeout_spacing = 1
|
||||
timeout_y = self.display_height - timeout_bar_height - 1 # Bottom edge
|
||||
|
||||
# Away Timeouts (Bottom Left)
|
||||
away_timeouts_remaining = game.get("away_timeouts", 0)
|
||||
for i in range(3):
|
||||
to_x = 2 + i * (timeout_bar_width + timeout_spacing)
|
||||
color = (255, 255, 255) if i < away_timeouts_remaining else (80, 80, 80) # White if available, gray if used
|
||||
draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0))
|
||||
|
||||
# Home Timeouts (Bottom Right)
|
||||
home_timeouts_remaining = game.get("home_timeouts", 0)
|
||||
for i in range(3):
|
||||
to_x = self.display_width - 2 - timeout_bar_width - (2-i) * (timeout_bar_width + timeout_spacing)
|
||||
color = (255, 255, 255) if i < home_timeouts_remaining else (80, 80, 80) # White if available, gray if used
|
||||
draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0))
|
||||
|
||||
# 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:
|
||||
record_font = self.fonts.get('detail', ImageFont.load_default())
|
||||
|
||||
# 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 - 4
|
||||
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 = 3
|
||||
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 - 3
|
||||
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 the text overlay onto the main image
|
||||
main_img = Image.alpha_composite(main_img, overlay)
|
||||
main_img = main_img.convert('RGB') # Convert for display
|
||||
|
||||
# Display the final image
|
||||
self.display_manager.image.paste(main_img, (0, 0))
|
||||
self.display_manager.update_display() # Update display here for live
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying live Football game: {e}", exc_info=True) # Changed log prefix
|
||||
@@ -1,380 +0,0 @@
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from src.base_classes.data_sources import ESPNDataSource
|
||||
from src.base_classes.sports import SportsCore, SportsLive
|
||||
from src.cache_manager import CacheManager
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
|
||||
class Hockey(SportsCore):
|
||||
"""Base class for hockey sports with common functionality."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
logger: logging.Logger,
|
||||
sport_key: str,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
self.data_source = ESPNDataSource(logger)
|
||||
self.sport = "hockey"
|
||||
self.show_shots_on_goal = self.mode_config.get("show_shots_on_goal", False)
|
||||
|
||||
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract relevant game details from ESPN NCAA FB API response."""
|
||||
# --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES ---
|
||||
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
|
||||
try:
|
||||
competition = game_event["competitions"][0]
|
||||
status = competition["status"]
|
||||
powerplay = False
|
||||
penalties = ""
|
||||
# A competitor may legitimately arrive without a "statistics"
|
||||
# array (pre-game feeds, and some in-progress ones). Reading it
|
||||
# unguarded raised KeyError inside the generator and dropped the
|
||||
# WHOLE event, discarding valid scores and status. Default to an
|
||||
# empty list so the saves/shots figures fall back to 0 instead.
|
||||
home_stats = home_team.get("statistics", [])
|
||||
away_stats = away_team.get("statistics", [])
|
||||
home_team_saves = next(
|
||||
(
|
||||
int(c["displayValue"])
|
||||
for c in home_stats
|
||||
if c.get("name") == "saves"
|
||||
),
|
||||
0,
|
||||
)
|
||||
home_team_saves_per = next(
|
||||
(
|
||||
float(c["displayValue"])
|
||||
for c in home_stats
|
||||
if c.get("name") == "savePct"
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
away_team_saves = next(
|
||||
(
|
||||
int(c["displayValue"])
|
||||
for c in away_stats
|
||||
if c.get("name") == "saves"
|
||||
),
|
||||
0,
|
||||
)
|
||||
away_team_saves_per = next(
|
||||
(
|
||||
float(c["displayValue"])
|
||||
for c in away_stats
|
||||
if c.get("name") == "savePct"
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
|
||||
home_shots = 0
|
||||
away_shots = 0
|
||||
if home_team_saves_per > 0:
|
||||
away_shots = round(home_team_saves / home_team_saves_per)
|
||||
if away_team_saves_per > 0:
|
||||
home_shots = round(away_team_saves / away_team_saves_per)
|
||||
if situation and status["type"]["state"] == "in":
|
||||
# Detect scoring events from status detail
|
||||
# status_detail = status["type"].get("detail", "")
|
||||
powerplay = situation.get("isPowerPlay", False)
|
||||
penalties = situation.get("penalties", "")
|
||||
|
||||
# Format period/quarter
|
||||
period = status.get("period", 0)
|
||||
period_text = ""
|
||||
if status["type"]["state"] == "in":
|
||||
if period == 0:
|
||||
period_text = "Start" # Before kickoff
|
||||
elif period >= 1 and period <= 3:
|
||||
period_text = f"P{period}" # OT starts after Q4
|
||||
elif period > 3:
|
||||
period_text = f"OT{period - 3}" # OT starts after Q4
|
||||
elif status["type"]["state"] == "post":
|
||||
if period > 3:
|
||||
period_text = "Final/OT"
|
||||
else:
|
||||
period_text = "Final"
|
||||
elif status["type"]["state"] == "pre":
|
||||
period_text = details.get("game_time", "") # Show time for upcoming
|
||||
|
||||
details.update(
|
||||
{
|
||||
"period": period,
|
||||
"period_text": period_text, # Formatted quarter/status
|
||||
"clock": status.get("displayClock", "0:00"),
|
||||
"power_play": powerplay,
|
||||
"penalties": penalties,
|
||||
"home_shots": home_shots,
|
||||
"away_shots": away_shots,
|
||||
}
|
||||
)
|
||||
|
||||
# Basic validation (can be expanded)
|
||||
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']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}"
|
||||
)
|
||||
|
||||
return details
|
||||
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
|
||||
|
||||
|
||||
class HockeyLive(Hockey, SportsLive):
|
||||
def __init__(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
display_manager: DisplayManager,
|
||||
cache_manager: CacheManager,
|
||||
logger: logging.Logger,
|
||||
sport_key: str,
|
||||
):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
|
||||
def _test_mode_update(self):
|
||||
if self.current_game and self.current_game["is_live"]:
|
||||
# For testing, we'll just update the clock to show it's working
|
||||
minutes = int(self.current_game["clock"].split(":")[0])
|
||||
seconds = int(self.current_game["clock"].split(":")[1])
|
||||
seconds -= 1
|
||||
if seconds < 0:
|
||||
seconds = 59
|
||||
minutes -= 1
|
||||
if minutes < 0:
|
||||
minutes = 19
|
||||
if self.current_game["period"] < 3:
|
||||
self.current_game["period"] += 1
|
||||
else:
|
||||
self.current_game["period"] = 1
|
||||
self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}"
|
||||
# Always update display in test mode
|
||||
|
||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Draw the detailed scorebug layout for a live 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
|
||||
) # Draw text elements on overlay first
|
||||
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')}"
|
||||
) # Changed log prefix
|
||||
# Draw placeholder text if logos fail
|
||||
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
|
||||
|
||||
# Draw logos (shifted slightly more inward than NHL perhaps)
|
||||
home_x = (
|
||||
self.display_width - home_logo.width + 10
|
||||
) # adjusted from 18 # Adjust position as needed
|
||||
home_y = center_y - (home_logo.height // 2)
|
||||
main_img.paste(home_logo, (home_x, home_y), home_logo)
|
||||
|
||||
away_x = -10 # adjusted from 18 # Adjust position as needed
|
||||
away_y = center_y - (away_logo.height // 2)
|
||||
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
|
||||
|
||||
# Period/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", "Period Break")
|
||||
|
||||
status_width = draw_overlay.textlength(
|
||||
period_clock_text, font=self.fonts["time"]
|
||||
)
|
||||
status_x = (self.display_width - status_width) // 2
|
||||
status_y = 1 # Position at top
|
||||
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
|
||||
score_y = (
|
||||
self.display_height // 2
|
||||
) - 3 # centered #from 14 # Position score higher
|
||||
self._draw_text_with_outline(
|
||||
draw_overlay, score_text, (score_x, score_y), self.fonts["score"]
|
||||
)
|
||||
|
||||
# Shots on Goal
|
||||
if self.show_shots_on_goal:
|
||||
shots_font = self.fonts.get('detail', 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 "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:
|
||||
record_font = self.fonts.get('detail', ImageFont.load_default())
|
||||
|
||||
# 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 - 1
|
||||
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 = 3
|
||||
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 - 3
|
||||
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 the text overlay onto the main image
|
||||
main_img = Image.alpha_composite(main_img, overlay)
|
||||
main_img = main_img.convert("RGB") # Convert for display
|
||||
|
||||
# Display the final image
|
||||
self.display_manager.image.paste(main_img, (0, 0))
|
||||
self.display_manager.update_display() # Update display here for live
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error displaying live Hockey game: {e}", exc_info=True
|
||||
) # Changed log prefix
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Sports scoreboard base classes.
|
||||
|
||||
Formerly the single module ``src/base_classes/sports.py``; now a package so
|
||||
capabilities can be composed instead of accumulating in one class. See
|
||||
docs/SPORTS_UNIFICATION.md for the architecture. The import path is
|
||||
unchanged: ``from src.base_classes.sports import SportsCore`` still works.
|
||||
"""
|
||||
|
||||
from .core import SportsCore
|
||||
from .modes import SportsLive, SportsRecent, SportsUpcoming
|
||||
|
||||
__all__ = [
|
||||
"SportsCore",
|
||||
"SportsUpcoming",
|
||||
"SportsRecent",
|
||||
"SportsLive",
|
||||
]
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Opt-in capabilities for the sports scoreboards.
|
||||
|
||||
Each module here is a feature that only *some* sports want. They are composed
|
||||
by inheritance (mixins) or selected by name (strategies) — never enabled by an
|
||||
``if self.<feature>_enabled:`` branch inside the base classes.
|
||||
|
||||
The distinction matters: hockey has no celebrations, so ``HockeyLive`` does not
|
||||
inherit :class:`~.celebrations.CelebrationMixin` and the celebration code is not
|
||||
in hockey's MRO at all. A bug in it cannot reach a plugin that never opted in.
|
||||
|
||||
See ``docs/SPORTS_UNIFICATION.md`` for the full rationale.
|
||||
"""
|
||||
|
||||
from .celebrations import CelebrationMixin
|
||||
from .rotation import (
|
||||
RotationStrategy,
|
||||
SimpleRotation,
|
||||
SmoothWeightedRotation,
|
||||
WeightedCycleRotation,
|
||||
get_rotation_strategy,
|
||||
register_rotation_strategy,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CelebrationMixin",
|
||||
"RotationStrategy",
|
||||
"SimpleRotation",
|
||||
"SmoothWeightedRotation",
|
||||
"WeightedCycleRotation",
|
||||
"get_rotation_strategy",
|
||||
"register_rotation_strategy",
|
||||
]
|
||||
@@ -1,418 +0,0 @@
|
||||
"""Score / win celebration takeover — an opt-in capability.
|
||||
|
||||
Four of the nine scoreboards celebrate (afl, nrl, soccer, football); the other
|
||||
five do not. This is a **mixin** rather than a flag inside ``SportsLive`` so the
|
||||
five that do not opt in have none of this code in their MRO: a bug here cannot
|
||||
reach hockey, and hockey's config never grows keys it ignores.
|
||||
|
||||
Usage — mix in *before* the mode class so its ``display`` runs first::
|
||||
|
||||
class SoccerLive(CelebrationMixin, SportsLive):
|
||||
def score_phrase(self, points, team_abbr):
|
||||
return secrets.choice(("GOOOOAAALLL!", f"{team_abbr} SCORES!"))
|
||||
|
||||
The two lineages spelled this differently (``_check_for_goal`` /
|
||||
``celebrate_opponent_goals`` in the soccer lineage, ``_check_for_score`` /
|
||||
``celebrate_opponent_scores`` in football) but the bodies were identical apart
|
||||
from three things, each of which is a seam here rather than a branch:
|
||||
|
||||
* **wording** — :meth:`score_phrase`, the hook football uses to say "TOUCHDOWN"
|
||||
from the points delta and soccer uses to say "GOOOOAAALLL";
|
||||
* **follow-up suppression** — :attr:`COALESCE_SCORING_SEQUENCE`, on for football
|
||||
where a touchdown lands as +6 then +1 a few seconds later, off elsewhere where
|
||||
two quick goals are two real events;
|
||||
* **team identity** — matching goes through ``_favorite_key``, so nrl can match
|
||||
on team id (its abbreviations are ambiguous) without core knowing why.
|
||||
|
||||
The config keys are read under both spellings, so a plugin adopting the mixin
|
||||
keeps working with the ``*_goals`` keys already in its published schema.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
class CelebrationMixin:
|
||||
"""Full-screen takeover when a tracked team scores or wins."""
|
||||
|
||||
#: Collapse increments that land while a celebration is already on screen
|
||||
#: into that one celebration. True for sports where a single scoring play
|
||||
#: arrives as more than one score update (football: touchdown +6, then the
|
||||
#: extra point +1). False where consecutive increments are distinct events —
|
||||
#: suppressing there would swallow a real goal.
|
||||
COALESCE_SCORING_SEQUENCE = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
mode_config = getattr(self, "mode_config", {}) or {}
|
||||
self.celebration_enabled = mode_config.get("celebration_enabled", True)
|
||||
# Coerced and floored at init: this value is compared numerically on the
|
||||
# display path, where a string from a hand-edited config would raise
|
||||
# TypeError outside any try block, and a zero or negative value would
|
||||
# arm a celebration that can never render.
|
||||
raw_duration = mode_config.get("celebration_duration", 8)
|
||||
try:
|
||||
self.celebration_duration = max(1.0, float(raw_duration))
|
||||
except (TypeError, ValueError):
|
||||
self.logger.warning(
|
||||
"[Celebrations] Unusable celebration_duration %r; using 8s. "
|
||||
"Set a positive number of seconds.",
|
||||
raw_duration,
|
||||
)
|
||||
self.celebration_duration = 8.0
|
||||
# Both spellings: the soccer lineage ships `celebrate_opponent_goals`,
|
||||
# football ships `celebrate_opponent_scores`. Whichever the plugin's
|
||||
# schema declares is the one its users have set.
|
||||
self.celebrate_opponent_scores = mode_config.get(
|
||||
"celebrate_opponent_scores",
|
||||
mode_config.get("celebrate_opponent_goals", False),
|
||||
)
|
||||
# Per-game score baselines: {game_id: {"away": int, "home": int}}
|
||||
self._score_baselines: Dict[str, Dict[str, int]] = {}
|
||||
# The active celebration (a game *snapshot*, so a win survives the game
|
||||
# leaving live_games) or None. See _start_celebration for the shape.
|
||||
self.active_celebration: Optional[Dict[str, Any]] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Override points
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def score_phrase(self, points: int, team_abbr: str) -> str:
|
||||
"""The wording for a score celebration.
|
||||
|
||||
``points`` is the score delta that triggered it, which sports with
|
||||
variable-value scores use to name the play. The default is deliberately
|
||||
sport-neutral; every celebrating plugin overrides it.
|
||||
"""
|
||||
return f"{team_abbr} SCORES!"
|
||||
|
||||
def win_phrase(self, team_abbr: str) -> str:
|
||||
"""The wording for a win celebration."""
|
||||
return f"{team_abbr} WINS!"
|
||||
|
||||
def _is_favorite(self, key: Optional[str]) -> bool:
|
||||
"""Whether ``key`` (whatever ``_favorite_key`` returns) is a favorite."""
|
||||
return bool(self.favorite_teams) and key in self.favorite_teams
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _score_to_int(score) -> Optional[int]:
|
||||
"""Coerce an ESPN score value (str / int / dict) to an int, or None."""
|
||||
try:
|
||||
if score is None:
|
||||
return None
|
||||
if isinstance(score, str):
|
||||
s = score.strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return int(float(s))
|
||||
except ValueError:
|
||||
numbers = re.findall(r"\d+", s)
|
||||
return int(numbers[0]) if numbers else None
|
||||
if isinstance(score, dict):
|
||||
return int(float(score.get("value", score.get("displayValue", 0))))
|
||||
return int(float(score))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _should_celebrate_for(self, game: Dict, side: str) -> bool:
|
||||
"""Whether a score by ``side`` in ``game`` should trigger a celebration."""
|
||||
if self._is_favorite(self._favorite_key(game, side)):
|
||||
return True
|
||||
if not self.favorite_teams:
|
||||
# No favorites configured: the user opted to show this game, so
|
||||
# celebrate any score in it.
|
||||
return True
|
||||
# Favorites exist but this team isn't one -> it's the opponent.
|
||||
return self.celebrate_opponent_scores
|
||||
|
||||
def prune_score_baselines(self, live_games: List[Dict]) -> None:
|
||||
"""Drop baselines for games no longer live.
|
||||
|
||||
Only :meth:`_check_for_win` removes entries, and it only fires for games
|
||||
seen to go final. A game that vanishes from the live list any other way
|
||||
— postponed, dropped by the feed, or simply still live when the board
|
||||
restarts — leaves its baseline behind forever, so on a board that runs
|
||||
all season the dict grows without bound.
|
||||
|
||||
Call this from ``update()`` with the current live set, alongside the
|
||||
equivalent pruning in :meth:`SmoothWeightedRotation.next_game`.
|
||||
"""
|
||||
live_ids = {g.get("id") for g in live_games}
|
||||
self._score_baselines = {
|
||||
gid: baseline
|
||||
for gid, baseline in self._score_baselines.items()
|
||||
if gid in live_ids
|
||||
}
|
||||
|
||||
def has_active_celebration(self) -> bool:
|
||||
"""True while a celebration is within its display window."""
|
||||
celebration = self.active_celebration
|
||||
return bool(celebration) and (
|
||||
time.time() - celebration["started_at"] < self.celebration_duration
|
||||
)
|
||||
|
||||
def _check_for_score(self, game: Dict) -> None:
|
||||
"""Compare a live game's score against its baseline and arm a
|
||||
celebration when a celebratable team's score increases."""
|
||||
if not self.celebration_enabled:
|
||||
return
|
||||
game_id = game.get("id")
|
||||
if not game_id:
|
||||
return
|
||||
away = self._score_to_int(game.get("away_score"))
|
||||
home = self._score_to_int(game.get("home_score"))
|
||||
if away is None or home is None:
|
||||
return
|
||||
|
||||
baseline = self._score_baselines.get(game_id)
|
||||
# Always refresh the baseline: a first sighting must never celebrate (a
|
||||
# game already in progress at boot would false-fire), and a decrement
|
||||
# (VAR, a correction) just re-bases silently.
|
||||
self._score_baselines[game_id] = {"away": away, "home": home}
|
||||
if baseline is None:
|
||||
return
|
||||
|
||||
away_delta = away - baseline["away"]
|
||||
home_delta = home - baseline["home"]
|
||||
if away_delta <= 0 and home_delta <= 0:
|
||||
return
|
||||
|
||||
# One takeover per scoring sequence, where the sport has such a thing.
|
||||
# The baseline is already advanced above, so nothing re-fires later.
|
||||
if self.COALESCE_SCORING_SEQUENCE and self.has_active_celebration():
|
||||
return
|
||||
|
||||
scored_side = None
|
||||
points = 0
|
||||
if away_delta > 0 and self._should_celebrate_for(game, "away"):
|
||||
scored_side, points = "away", away_delta
|
||||
if scored_side is None and home_delta > 0 and self._should_celebrate_for(
|
||||
game, "home"
|
||||
):
|
||||
scored_side, points = "home", home_delta
|
||||
if scored_side is None:
|
||||
return
|
||||
|
||||
self._start_celebration(
|
||||
game,
|
||||
"score",
|
||||
scored_side=scored_side,
|
||||
team_abbr=game.get(f"{scored_side}_abbr", ""),
|
||||
away_score=away,
|
||||
home_score=home,
|
||||
points=points,
|
||||
)
|
||||
|
||||
def _check_for_win(self, game: Dict) -> None:
|
||||
"""When a game we were tracking live goes final, arm a win celebration
|
||||
if a favorite won. Fires at most once per game."""
|
||||
if not self.celebration_enabled:
|
||||
return
|
||||
game_id = game.get("id")
|
||||
if not game_id:
|
||||
return
|
||||
# Only celebrate wins for games we actually watched go live: one seen
|
||||
# for the first time already-final (the board started after full time)
|
||||
# has no baseline and must not fire.
|
||||
if game_id not in self._score_baselines:
|
||||
return
|
||||
# Consume the baseline so this can only fire once.
|
||||
self._score_baselines.pop(game_id, None)
|
||||
|
||||
away = self._score_to_int(game.get("away_score"))
|
||||
home = self._score_to_int(game.get("home_score"))
|
||||
if away is None or home is None:
|
||||
return
|
||||
|
||||
if away > home:
|
||||
winner_side = "away"
|
||||
elif home > away:
|
||||
winner_side = "home"
|
||||
else:
|
||||
return # draw -> no win celebration
|
||||
|
||||
# Wins are gated strictly on favorites: every game ends, so the
|
||||
# "no favorites -> celebrate all" score fallback would be far too noisy.
|
||||
if not self._is_favorite(self._favorite_key(game, winner_side)):
|
||||
return
|
||||
|
||||
self._start_celebration(
|
||||
game,
|
||||
"win",
|
||||
scored_side=winner_side,
|
||||
team_abbr=game.get(f"{winner_side}_abbr", ""),
|
||||
away_score=away,
|
||||
home_score=home,
|
||||
)
|
||||
|
||||
def _start_celebration(
|
||||
self,
|
||||
game: Dict,
|
||||
kind: str,
|
||||
scored_side: str,
|
||||
team_abbr: str,
|
||||
away_score: int,
|
||||
home_score: int,
|
||||
points: int = 0,
|
||||
) -> None:
|
||||
"""Arm a celebration. ``scored_side`` ('away'/'home') is the side whose
|
||||
score digit gets highlighted."""
|
||||
phrase = (
|
||||
self.win_phrase(team_abbr)
|
||||
if kind == "win"
|
||||
else self.score_phrase(points, team_abbr)
|
||||
)
|
||||
|
||||
self.active_celebration = {
|
||||
"kind": kind,
|
||||
"game": dict(game), # snapshot: survives the game leaving live_games
|
||||
"scored_side": scored_side,
|
||||
"team_abbr": team_abbr,
|
||||
"away_score": away_score,
|
||||
"home_score": home_score,
|
||||
"started_at": time.time(),
|
||||
"phrase": phrase,
|
||||
}
|
||||
# Pin focus to the involved game so the post-celebration scorebug
|
||||
# resumes on it.
|
||||
self.current_game = dict(game)
|
||||
self.logger.info(
|
||||
f"[Celebrations] {kind} armed: {phrase} "
|
||||
f"[{game.get('away_abbr')} {away_score}-{home_score} {game.get('home_abbr')}]"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fit_font(self, draw, text: str, max_width: int, fonts: List):
|
||||
"""The first font whose rendered ``text`` fits ``max_width``, falling
|
||||
back to the last (smallest) font."""
|
||||
for font in fonts:
|
||||
if draw.textlength(text, font=font) <= max_width - 2:
|
||||
return font
|
||||
return fonts[-1]
|
||||
|
||||
def _draw_celebration_layout(
|
||||
self, celebration: Dict, force_clear: bool = False
|
||||
) -> None:
|
||||
"""Render the full-screen score/win takeover."""
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
display_width = (
|
||||
self.display_manager.matrix.width
|
||||
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
|
||||
else self.display_width
|
||||
)
|
||||
display_height = (
|
||||
self.display_manager.matrix.height
|
||||
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
|
||||
else self.display_height
|
||||
)
|
||||
|
||||
elapsed = time.time() - celebration["started_at"]
|
||||
game = celebration["game"]
|
||||
|
||||
# Background: a brief color flash for the first ~1.2s, then black.
|
||||
bg = (0, 0, 0, 255)
|
||||
if elapsed < 1.2 and int(elapsed / 0.2) % 2 == 0:
|
||||
bg = (12, 12, 48, 255)
|
||||
main_img = Image.new("RGBA", (display_width, display_height), bg)
|
||||
overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
|
||||
# Logos at the edges (best-effort: a logo failure must not blank the
|
||||
# celebration).
|
||||
try:
|
||||
center_y = display_height // 2
|
||||
home_logo = self._load_and_resize_logo(
|
||||
game.get("home_id"), game.get("home_abbr"),
|
||||
game.get("home_logo_path"), game.get("home_logo_url"),
|
||||
)
|
||||
away_logo = self._load_and_resize_logo(
|
||||
game.get("away_id"), game.get("away_abbr"),
|
||||
game.get("away_logo_path"), game.get("away_logo_url"),
|
||||
)
|
||||
if home_logo:
|
||||
main_img.paste(
|
||||
home_logo,
|
||||
(display_width - home_logo.width + 2, center_y - home_logo.height // 2),
|
||||
home_logo,
|
||||
)
|
||||
if away_logo:
|
||||
main_img.paste(
|
||||
away_logo, (-2, center_y - away_logo.height // 2), away_logo
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"[Celebrations] Logo load failed: {e}")
|
||||
|
||||
# Phrase across the top, shrunk to fit the panel width.
|
||||
phrase = celebration["phrase"]
|
||||
phrase_font = self._fit_font(
|
||||
draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]]
|
||||
)
|
||||
phrase_width = draw.textlength(phrase, font=phrase_font)
|
||||
self._draw_text_with_outline(
|
||||
draw, phrase, ((display_width - phrase_width) // 2, 1), phrase_font
|
||||
)
|
||||
|
||||
# Score centered low, with the scoring/winning side's digit pulsing in a
|
||||
# highlight color so the change reads at a glance.
|
||||
away_text = str(celebration["away_score"])
|
||||
home_text = str(celebration["home_score"])
|
||||
score_font = self.fonts["score"]
|
||||
segments = [
|
||||
(away_text, celebration["scored_side"] == "away"),
|
||||
("-", False),
|
||||
(home_text, celebration["scored_side"] == "home"),
|
||||
]
|
||||
total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments)
|
||||
highlight = (255, 255, 0) if int(elapsed * 4) % 2 == 0 else (255, 170, 0)
|
||||
x = (display_width - total_width) // 2
|
||||
y = display_height - 14
|
||||
for seg, is_highlight in segments:
|
||||
color = highlight if is_highlight else (255, 255, 255)
|
||||
self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color)
|
||||
x += draw.textlength(seg, font=score_font)
|
||||
|
||||
main_img = Image.alpha_composite(main_img, overlay).convert("RGB")
|
||||
self.display_manager.image = main_img
|
||||
self.display_manager.update_display()
|
||||
|
||||
def display(self, force_clear: bool = False) -> bool:
|
||||
"""Render an active celebration as a full-screen takeover; otherwise
|
||||
defer to the normal live scorebug."""
|
||||
if not self.is_enabled:
|
||||
return False
|
||||
celebration = self.active_celebration
|
||||
if celebration:
|
||||
if self.has_active_celebration():
|
||||
try:
|
||||
self._draw_celebration_layout(celebration, force_clear)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"[Celebrations] Error drawing celebration: {e}", exc_info=True
|
||||
)
|
||||
# Disarm rather than retry: the same render would fail on
|
||||
# every frame for the rest of the window, logging a
|
||||
# traceback each time and leaving the scorebug off screen.
|
||||
self.active_celebration = None
|
||||
self.last_game_switch = time.time()
|
||||
else:
|
||||
self.active_celebration = None
|
||||
# Reset the dwell so the scorebug resumes on the scoring/winning
|
||||
# game for a full duration before rotation can move on.
|
||||
self.last_game_switch = time.time()
|
||||
return super().display(force_clear)
|
||||
@@ -1,246 +0,0 @@
|
||||
"""Live-rotation strategies — which live game to show next.
|
||||
|
||||
The nine plugin copies grew three spellings of this, and the survey behind
|
||||
``docs/SPORTS_UNIFICATION.md`` found they are all the *same* Smooth Weighted
|
||||
Round-Robin algorithm in two shapes:
|
||||
|
||||
* an **incremental picker** that holds weight state across calls and answers
|
||||
"what next?" one game at a time (afl / nrl / soccer's ``_swrr_advance``), and
|
||||
* a **precomputed cycle** that returns a full list of game ids up front
|
||||
(football / baseball / basketball's ``_build_weighted_schedule`` and hockey's
|
||||
``_build_rotation_schedule``, which differ only in loop shape).
|
||||
|
||||
They agree *within* a cycle — SWRR is deterministic — and differ only at cycle
|
||||
boundaries, where the incremental form has no seam and the precomputed form
|
||||
restarts. That is a real behavioral difference, so core ships both rather than
|
||||
declaring a winner, and a plugin picks one by name:
|
||||
|
||||
self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight)
|
||||
|
||||
Core never learns which sport is asking. A plugin with a genuinely novel
|
||||
ordering registers its own strategy instead of core growing a branch::
|
||||
|
||||
register_rotation_strategy("my-order", MyRotation)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Dict, List, Optional, Type
|
||||
|
||||
|
||||
def _game_id(game: Dict) -> Optional[str]:
|
||||
"""The rotation key for a game, or None if it has no usable id."""
|
||||
return game.get("id")
|
||||
|
||||
|
||||
class RotationStrategy:
|
||||
"""Base class for live-rotation ordering.
|
||||
|
||||
Subclasses implement :meth:`schedule`; :meth:`next_game` has a working
|
||||
default derived from it. Strategies whose natural shape is incremental
|
||||
override :meth:`next_game` instead and derive :meth:`schedule`.
|
||||
|
||||
:param weight_for: callable mapping a game dict to a positive integer
|
||||
weight — how many turns it gets per turn of a weight-1 game. Supplied by
|
||||
the host so the *favorites* policy stays with the plugin and this module
|
||||
stays free of any notion of what a favorite is. Defaults to equal
|
||||
weights, which makes every strategy a plain round robin.
|
||||
"""
|
||||
|
||||
#: Name this strategy is registered under. Set by :func:`register_rotation_strategy`.
|
||||
name: str = ""
|
||||
|
||||
#: Ceiling on a per-game weight. A cycle is ``sum(weights)`` long and each
|
||||
#: step scans every game, so an unbounded weight — a misread config field,
|
||||
#: say — would spin the display thread for an unbounded time. On a Pi that
|
||||
#: stalls rendering outright, so the bound is clamped like the floor is.
|
||||
MAX_WEIGHT = 16
|
||||
|
||||
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
|
||||
self._weight_for = weight_for or (lambda game: 1)
|
||||
|
||||
def weights(self, games: List[Dict]) -> Dict[str, int]:
|
||||
"""``{game_id: weight}`` for games that have an id, in ``games`` order.
|
||||
|
||||
A weight below 1 is clamped up: a zero or negative weight would starve
|
||||
a game out of the rotation entirely, which no caller means to express
|
||||
and which would make ``total_weight`` collapse. It is clamped down at
|
||||
:attr:`MAX_WEIGHT` for the reason documented there.
|
||||
"""
|
||||
weights: Dict[str, int] = {}
|
||||
for game in games:
|
||||
gid = _game_id(game)
|
||||
if gid is None:
|
||||
continue
|
||||
try:
|
||||
weight = int(self._weight_for(game))
|
||||
except (TypeError, ValueError):
|
||||
weight = 1
|
||||
weights[gid] = min(self.MAX_WEIGHT, max(1, weight))
|
||||
return weights
|
||||
|
||||
def schedule(self, games: List[Dict]) -> List[str]:
|
||||
"""Game ids in display order for one cycle. Ids may repeat."""
|
||||
raise NotImplementedError
|
||||
|
||||
def next_game(self, games: List[Dict]) -> Optional[Dict]:
|
||||
"""The next game to display, or None when there is nothing to show."""
|
||||
order = self.schedule(games)
|
||||
if not order:
|
||||
return None
|
||||
by_id = {gid: g for g in games if (gid := _game_id(g)) is not None}
|
||||
return by_id.get(order[0])
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Drop any accumulated state. Stateless strategies need do nothing."""
|
||||
|
||||
|
||||
class SimpleRotation(RotationStrategy):
|
||||
"""Plain round robin: every live game once per cycle, weights ignored.
|
||||
|
||||
The fallback for a plugin that wants strictly even rotation regardless of
|
||||
favorites.
|
||||
"""
|
||||
|
||||
def schedule(self, games: List[Dict]) -> List[str]:
|
||||
return [gid for g in games if (gid := _game_id(g)) is not None]
|
||||
|
||||
|
||||
class WeightedCycleRotation(RotationStrategy):
|
||||
"""Precomputed SWRR cycle — the football / baseball / basketball / hockey shape.
|
||||
|
||||
Returns a full cycle of ``sum(weights)`` ids with repeats spaced evenly
|
||||
rather than clumped, highest weight scheduled first. When no game carries a
|
||||
boost the cycle degenerates to a single pass in ``games`` order, which is
|
||||
exactly the plain round robin it replaced.
|
||||
"""
|
||||
|
||||
def schedule(self, games: List[Dict]) -> List[str]:
|
||||
weights = self.weights(games)
|
||||
if not weights:
|
||||
return []
|
||||
total_weight = sum(weights.values())
|
||||
if total_weight <= len(weights):
|
||||
# No boost in effect — plain order, one pass. (Also the guard that
|
||||
# keeps the loop below from being O(total_weight) for nothing.)
|
||||
return list(weights)
|
||||
|
||||
current = {gid: 0 for gid in weights}
|
||||
order: List[str] = []
|
||||
for _ in range(total_weight):
|
||||
for gid, weight in weights.items():
|
||||
current[gid] += weight
|
||||
picked = max(current, key=lambda gid: current[gid])
|
||||
current[picked] -= total_weight
|
||||
order.append(picked)
|
||||
return order
|
||||
|
||||
|
||||
class SmoothWeightedRotation(RotationStrategy):
|
||||
"""Incremental SWRR — the afl / nrl / soccer shape.
|
||||
|
||||
Weight state persists across calls, so there is no fixed-length cycle and
|
||||
therefore no clustering seam at a cycle boundary. A game seen for the first
|
||||
time starts at weight 0 and receives its full weight on the next call, so a
|
||||
favorite's game that has just gone live naturally wins the first pick after
|
||||
it appears — "queued first on refresh" without a special-cased branch.
|
||||
|
||||
State for games no longer live is dropped on each call, so a long-running
|
||||
board does not accumulate entries for finished games.
|
||||
"""
|
||||
|
||||
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
|
||||
super().__init__(weight_for)
|
||||
self._current: Dict[str, int] = {}
|
||||
|
||||
def reset(self) -> None:
|
||||
self._current = {}
|
||||
|
||||
def next_game(self, games: List[Dict]) -> Optional[Dict]:
|
||||
if not games:
|
||||
return None
|
||||
weights = self.weights(games)
|
||||
if not weights:
|
||||
return None
|
||||
|
||||
# Keep state only for games still live.
|
||||
self._current = {
|
||||
gid: value for gid, value in self._current.items() if gid in weights
|
||||
}
|
||||
for gid, weight in weights.items():
|
||||
self._current[gid] = self._current.get(gid, 0) + weight
|
||||
|
||||
total_weight = sum(weights.values())
|
||||
# Iterate in `games` order so ties break toward the feed's ordering,
|
||||
# which is what the plugin copies did and what makes the no-boost case
|
||||
# identical to a plain round robin.
|
||||
ids_in_order = [gid for g in games if (gid := _game_id(g)) in weights]
|
||||
best = max(ids_in_order, key=lambda gid: self._current[gid])
|
||||
self._current[best] -= total_weight
|
||||
return next(g for g in games if _game_id(g) == best)
|
||||
|
||||
def schedule(self, games: List[Dict]) -> List[str]:
|
||||
"""One cycle's worth of picks, without disturbing live state.
|
||||
|
||||
Derived by running the picker forward on a copy, so the returned order
|
||||
is exactly what repeated :meth:`next_game` calls would produce from the
|
||||
current state — callers can use it to preview or log the rotation
|
||||
without perturbing it.
|
||||
"""
|
||||
weights = self.weights(games)
|
||||
if not weights:
|
||||
return []
|
||||
# type(self), not this class: a subclass that overrides next_game must
|
||||
# be previewed through its own ordering, or the returned order is not
|
||||
# the one repeated next_game calls would produce — which is exactly
|
||||
# what this method promises.
|
||||
preview = type(self)(self._weight_for)
|
||||
preview._current = dict(self._current)
|
||||
order: List[str] = []
|
||||
for _ in range(sum(weights.values())):
|
||||
picked = preview.next_game(games)
|
||||
if picked is None:
|
||||
break
|
||||
order.append(_game_id(picked))
|
||||
return order
|
||||
|
||||
|
||||
_REGISTRY: Dict[str, Type[RotationStrategy]] = {}
|
||||
|
||||
|
||||
def register_rotation_strategy(name: str, factory: Type[RotationStrategy]) -> None:
|
||||
"""Register a rotation strategy under ``name``.
|
||||
|
||||
When a plugin needs an ordering that core does not ship, it registers its
|
||||
own here instead of core growing a sport-specific branch. Re-registering a
|
||||
name replaces it, so a plugin may also override a built-in for itself.
|
||||
"""
|
||||
if not name:
|
||||
raise ValueError("rotation strategy name must be a non-empty string")
|
||||
# Fail at registration, not at the first schedule() call several frames
|
||||
# later, where the cause is no longer on the stack.
|
||||
if not (isinstance(factory, type) and issubclass(factory, RotationStrategy)):
|
||||
raise TypeError(
|
||||
f"rotation strategy {name!r} must be a RotationStrategy subclass, "
|
||||
f"got {factory!r}"
|
||||
)
|
||||
factory.name = name
|
||||
_REGISTRY[name] = factory
|
||||
|
||||
|
||||
def get_rotation_strategy(
|
||||
name: str, weight_for: Optional[Callable[[Dict], int]] = None
|
||||
) -> RotationStrategy:
|
||||
"""Build the strategy registered under ``name``.
|
||||
|
||||
Falls back to ``"simple"`` for an unknown name rather than raising: the name
|
||||
arrives from user config, and a typo should cost the boost, not the
|
||||
scoreboard.
|
||||
"""
|
||||
factory = _REGISTRY.get(name) or _REGISTRY["simple"]
|
||||
return factory(weight_for=weight_for)
|
||||
|
||||
|
||||
register_rotation_strategy("simple", SimpleRotation)
|
||||
register_rotation_strategy("weighted", WeightedCycleRotation)
|
||||
register_rotation_strategy("swrr", SmoothWeightedRotation)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -50,8 +50,7 @@ class BaseOddsManager:
|
||||
# 2026-08-04 it began 403ing browser strings and bare custom tokens
|
||||
# alike; what it accepts is a token with a URL that says who is
|
||||
# calling. Every other ESPN caller in the tree already sends this
|
||||
# (src/common/api_helper.py, src/base_classes/data_sources.py); the
|
||||
# odds path was simply missed, and it is the one whose failures cost
|
||||
# (src/common/api_helper.py); the odds path was simply missed, and it is the one whose failures cost
|
||||
# the caller its whole update budget.
|
||||
#
|
||||
# Deliberately no retry adapter, unlike api_helper: retries multiply
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
Ten helpers are byte-identical (executable AST, docstrings stripped) in the
|
||||
scoreboard plugins' ``sports.py`` and have no equivalent elsewhere in
|
||||
``src/common``. The bodies below were copied from those plugin copies -- not
|
||||
from ``src/base_classes`` -- at ledmatrix-plugins ``f09bff2`` (origin/main,
|
||||
2026-09-14):
|
||||
``src/common``. The bodies below were copied from those plugin copies at
|
||||
ledmatrix-plugins ``f09bff2`` (origin/main, 2026-09-14):
|
||||
|
||||
- In all nine (afl, baseball, basketball, football, hockey, lacrosse, nrl,
|
||||
soccer, ufc): ``_clamp_window``, ``_clamp_seconds``, ``_logo_needs_refresh``
|
||||
@@ -34,9 +33,9 @@ where both can. Plugins import this and floor ``ledmatrix_min_version`` on the
|
||||
first core release that ships it (see ``CHANGELOG.md``).
|
||||
|
||||
``_favorite_key`` is the one method not taken from the plugins: it is the
|
||||
override point from ``src/base_classes/sports/core.py``, carried here so later
|
||||
phases (shared celebrations and game selection) have a hardware-free home for
|
||||
the seam. No plugin defines it today and nothing in this module calls it.
|
||||
override point from the since-removed ``src/base_classes`` sports core,
|
||||
carried here so later phases (shared celebrations and game selection) have a
|
||||
hardware-free home for the seam. No plugin defines it today and nothing in this module calls it.
|
||||
|
||||
WHAT A HOST MUST PROVIDE
|
||||
------------------------
|
||||
@@ -80,8 +79,8 @@ runs. By convention list it after ``SportsCoreSharedMixin``::
|
||||
|
||||
HARDWARE-FREE
|
||||
-------------
|
||||
Nothing here may import ``src.display_manager``, ``src.base_classes``,
|
||||
``src.plugin_system`` or anything else that reaches ``rgbmatrix`` at module
|
||||
Nothing here may import ``src.display_manager``, ``src.plugin_system`` or
|
||||
anything else that reaches ``rgbmatrix`` at module
|
||||
level; ``test/test_common_is_hardware_free.py`` enforces that for all of
|
||||
``src/common``. ``logo_needs_refresh`` imports ``src.logo_downloader`` lazily,
|
||||
exactly as the plugin copy does.
|
||||
@@ -224,8 +223,8 @@ class SportsHelpersMixin:
|
||||
containing that string. The default returns ``None`` for a missing
|
||||
abbreviation, which never matches.
|
||||
|
||||
Carried from ``src/base_classes/sports/core.py`` for later phases;
|
||||
nothing in this module calls it yet.
|
||||
Carried from the since-removed ``src/base_classes`` sports core for
|
||||
later phases; nothing in this module calls it yet.
|
||||
"""
|
||||
return game.get(f"{side}_abbr")
|
||||
|
||||
|
||||
@@ -99,8 +99,7 @@ def normalize_legacy_booleans(config: Any, schema: Any,
|
||||
#: config section, so they are allowed in every plugin's config whether or not
|
||||
#: the plugin's schema declares them. The one list for validation, for the web
|
||||
#: save filter and for the load-time checks -- a private copy is how JSON saves
|
||||
#: came to drop ``skin`` and the ``vegas_*`` keys while the validator accepted
|
||||
#: them.
|
||||
#: came to drop the ``vegas_*`` keys while the validator accepted them.
|
||||
#:
|
||||
#: Values are the schema used when the plugin does not declare the property.
|
||||
CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
||||
@@ -123,19 +122,6 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
||||
"default": False,
|
||||
"description": "Enable live priority takeover when plugin has live content"
|
||||
},
|
||||
# Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an enum here:
|
||||
# validation must keep passing when a configured skin gets uninstalled
|
||||
# (rendering falls back to built-in). The install-dependent enum is
|
||||
# injected only at serve time (inject_skin_selector) for the web UI
|
||||
# dropdown.
|
||||
"skin": {
|
||||
"type": ["string", "object", "null"],
|
||||
"description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}"
|
||||
},
|
||||
"skin_options": {
|
||||
"type": "object",
|
||||
"description": "Options passed through to the selected skin"
|
||||
},
|
||||
# Vegas tuning read by vegas_mode/plugin_adapter.py and base_plugin.py.
|
||||
# Left untyped: the adapter validates them itself and ignores a bad
|
||||
# value with a log line, so a stored one must never block a save.
|
||||
@@ -158,6 +144,32 @@ CORE_VEGAS_TUNING_KEYS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
#: Per-plugin keys the core used to own and no longer reads. ``skin`` and
|
||||
#: ``skin_options`` belonged to the skin system, which was removed; a
|
||||
#: config.json written before then can still carry them in any plugin section,
|
||||
#: and most plugin schemas set ``additionalProperties: false``. They are
|
||||
#: dropped wherever a section is prepared (prepare_plugin_config) or validated,
|
||||
#: and the web saves drop them from the stored section, so an old config loads
|
||||
#: and saves without a validation error and loses them on its next save.
|
||||
RETIRED_PLUGIN_KEYS = frozenset({'skin', 'skin_options'})
|
||||
|
||||
|
||||
def drop_retired_plugin_keys(config: Any, schema: Any) -> Any:
|
||||
"""``config`` without the RETIRED_PLUGIN_KEYS its plugin's schema leaves undeclared.
|
||||
|
||||
A plugin whose schema declares one of these names owns it and keeps it;
|
||||
without a schema nothing is dropped. Never mutates ``config``, and returns
|
||||
it unchanged when there is nothing to drop.
|
||||
"""
|
||||
if not isinstance(config, dict) or not isinstance(schema, dict) \
|
||||
or RETIRED_PLUGIN_KEYS.isdisjoint(config):
|
||||
return config
|
||||
declared = schema.get('properties')
|
||||
declared = declared if isinstance(declared, dict) else {}
|
||||
return {key: value for key, value in config.items()
|
||||
if key not in RETIRED_PLUGIN_KEYS or key in declared}
|
||||
|
||||
|
||||
def with_core_plugin_properties(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""A deep copy of a plugin schema with CORE_PLUGIN_PROPERTIES allowed.
|
||||
|
||||
@@ -299,14 +311,15 @@ def prepare_plugin_config(config: Any, schema: Optional[Dict[str, Any]],
|
||||
changed_paths: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""The config a plugin runs with, from its stored (or submitted) section.
|
||||
|
||||
Legacy booleans are read as ``{"enabled": ...}`` objects
|
||||
(normalize_legacy_booleans), then schema defaults fill in whatever is
|
||||
missing. Loading a plugin, both config saves, GET /plugins/config, hot
|
||||
reload and the dev tools all go through this, so a plugin sees the same
|
||||
shape however its config reached it.
|
||||
Retired core keys are dropped (drop_retired_plugin_keys), legacy booleans
|
||||
are read as ``{"enabled": ...}`` objects (normalize_legacy_booleans), then
|
||||
schema defaults fill in whatever is missing. Loading a plugin, both config
|
||||
saves, GET /plugins/config, hot reload and the dev tools all go through
|
||||
this, so a plugin sees the same shape however its config reached it.
|
||||
"""
|
||||
config = config if isinstance(config, dict) else {}
|
||||
if schema:
|
||||
config = drop_retired_plugin_keys(config, schema)
|
||||
config = normalize_legacy_booleans(config, schema, changed_paths)
|
||||
return merge_config_defaults(config, defaults)
|
||||
|
||||
@@ -620,7 +633,8 @@ class SchemaManager:
|
||||
# Core plugin properties (CORE_PLUGIN_PROPERTIES) are handled by
|
||||
# the base plugin system and should not cause validation failures:
|
||||
# they are allowed even when the plugin's schema doesn't declare
|
||||
# them, and never required.
|
||||
# them, and never required. Retired ones are ignored.
|
||||
config = drop_retired_plugin_keys(config, schema)
|
||||
enhanced_schema = with_core_plugin_properties(schema)
|
||||
if plugin_id:
|
||||
declared = schema.get("properties", {}) if isinstance(schema, dict) else {}
|
||||
@@ -658,53 +672,6 @@ class SchemaManager:
|
||||
self.logger.error(error_msg)
|
||||
return False, [error_msg]
|
||||
|
||||
def inject_skin_selector(self, schema: Dict[str, Any], plugin_id: str,
|
||||
current_value: Any = None) -> Dict[str, Any]:
|
||||
"""Return a copy of a plugin's schema with a "skin" dropdown added
|
||||
when installed skins target this plugin (docs/SKIN_SYSTEM.md).
|
||||
|
||||
Serve-time only — validation never sees this enum, so a config
|
||||
referencing an uninstalled skin stays valid (rendering falls back
|
||||
to the built-in layout). The currently-configured value is always
|
||||
included in the enum for the same reason: the dropdown must be able
|
||||
to display a selection whose skin was removed.
|
||||
"""
|
||||
# A per-mode mapping ({"live": ..., "recent": ...}) can't be edited
|
||||
# through a string dropdown — injecting one would let the form save
|
||||
# a string over the mapping. Leave the schema alone; per-mode users
|
||||
# edit via the raw JSON config editor.
|
||||
if isinstance(current_value, dict):
|
||||
return schema
|
||||
|
||||
try:
|
||||
from src.skin_system import skin_runtime
|
||||
matching = skin_runtime.skins_for_plugin(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Skin discovery failed for {plugin_id}: {e}")
|
||||
return schema
|
||||
|
||||
choices = sorted(matching.keys())
|
||||
if isinstance(current_value, str) and current_value and \
|
||||
current_value != "built-in" and current_value not in choices:
|
||||
choices.append(current_value)
|
||||
if not choices:
|
||||
return schema
|
||||
|
||||
enhanced = copy.deepcopy(schema)
|
||||
enhanced.setdefault("properties", {})
|
||||
if "skin" not in enhanced["properties"]:
|
||||
names = {sid: (matching.get(sid, {}).get("name") or sid) for sid in choices}
|
||||
enhanced["properties"]["skin"] = {
|
||||
"type": "string",
|
||||
"title": "Visual Skin",
|
||||
"description": "Replace this scoreboard's look with an installed skin "
|
||||
"(data, scheduling, and vegas mode are unaffected)",
|
||||
"enum": ["built-in", *choices],
|
||||
"enumNames": ["Built-in", *(names[sid] for sid in choices)],
|
||||
"default": "built-in"
|
||||
}
|
||||
return enhanced
|
||||
|
||||
def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
Format a validation error into a readable message.
|
||||
|
||||
@@ -53,6 +53,17 @@ class PluginStoreManager:
|
||||
# "..", "../x") into a filesystem path that purge_uninstalled_plugins
|
||||
# would delete — an empty id resolves to the plugins root itself.
|
||||
_PLUGIN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
@staticmethod
|
||||
def is_plugin_entry(entry) -> bool:
|
||||
"""Whether a registry entry is a plugin core can install.
|
||||
|
||||
A missing ``type`` means plugin. Anything else (registries used to
|
||||
carry ``"type": "skin"`` entries, and a custom registry still can) is
|
||||
hidden from the store and refused at install, rather than being
|
||||
unpacked into the plugins directory as if it were a plugin.
|
||||
"""
|
||||
return isinstance(entry, dict) and (entry.get('type') or 'plugin') == 'plugin'
|
||||
|
||||
def __init__(self, plugins_dir: str = "plugins",
|
||||
uninstalled_registry_path: Optional[str] = None):
|
||||
@@ -1314,18 +1325,10 @@ class PluginStoreManager:
|
||||
if not plugin_info:
|
||||
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
||||
return False
|
||||
|
||||
# Visual skins share the registry. _install_skin_from_info can put one
|
||||
# in skins/, but no current scoreboard plugin renders skins, so the
|
||||
# store refuses them rather than installing something that does
|
||||
# nothing (docs/SKIN_SYSTEM.md). Manual installs under skins/ and
|
||||
# uninstall_skin are unaffected.
|
||||
if (plugin_info.get('type') or 'plugin') == 'skin':
|
||||
from src.skin_system import SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE
|
||||
if not SKINS_RENDER_SUPPORTED:
|
||||
self.logger.error(f"Not installing skin {plugin_id}: {SKINS_UNSUPPORTED_MESSAGE}")
|
||||
return False
|
||||
return self._install_skin_from_info(plugin_id, plugin_info, branch)
|
||||
if not self.is_plugin_entry(plugin_info):
|
||||
self.logger.error(f"Not installing {plugin_id}: registry entry type "
|
||||
f"{plugin_info.get('type')!r} is not a plugin")
|
||||
return False
|
||||
|
||||
repo_url = plugin_info.get('repo')
|
||||
if not repo_url:
|
||||
@@ -2479,152 +2482,6 @@ class PluginStoreManager:
|
||||
continue
|
||||
return None
|
||||
|
||||
_SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
|
||||
|
||||
def _resolve_skin_target(self, skin_id: str) -> Optional[Path]:
|
||||
"""Validate an externally-supplied skin id and resolve it to a path
|
||||
strictly inside the skins directory. Returns None (after logging)
|
||||
for ids that are malformed or would escape the directory — registry
|
||||
entries and manifests are external input and must not be able to
|
||||
write or delete outside skins/."""
|
||||
from src.skin_system import skin_runtime
|
||||
|
||||
if not isinstance(skin_id, str) or not self._SKIN_ID_PATTERN.match(skin_id) \
|
||||
or '..' in skin_id:
|
||||
self.logger.error(f"Rejecting unsafe skin id: {skin_id!r}")
|
||||
return None
|
||||
skins_dir = skin_runtime.get_skins_directory().resolve()
|
||||
target = (skins_dir / skin_id).resolve()
|
||||
if target.parent != skins_dir:
|
||||
self.logger.error(f"Skin id {skin_id!r} escapes the skins directory; rejecting")
|
||||
return None
|
||||
return target
|
||||
|
||||
def _install_skin_from_info(self, skin_id: str, skin_info: Dict,
|
||||
branch: Optional[str] = None) -> bool:
|
||||
"""Install a registry entry of type "skin" into skins/<id>/.
|
||||
|
||||
Reuses the plugin download machinery (git / monorepo zip / archive)
|
||||
but validates skin.json instead of manifest.json and never installs
|
||||
dependencies — skins are render-only (stdlib + PIL + the provided
|
||||
SkinContext), which is also what keeps them safe to iterate on.
|
||||
|
||||
Downloads into a staging directory and validates there; the
|
||||
existing installation is only replaced after the new one passes,
|
||||
so a failed download or bad manifest can't destroy a working skin.
|
||||
"""
|
||||
from src.skin_system import skin_runtime
|
||||
from src.skin_system.skin_base import SKIN_API_VERSION
|
||||
|
||||
repo_url = skin_info.get('repo')
|
||||
if not repo_url:
|
||||
self.logger.error(f"Skin {skin_id} missing repository URL")
|
||||
return False
|
||||
|
||||
target = self._resolve_skin_target(skin_id)
|
||||
if target is None:
|
||||
return False
|
||||
skins_dir = target.parent
|
||||
skins_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Leading "_" keeps staging invisible to skin discovery
|
||||
staging = skins_dir / f"_staging-{skin_id}"
|
||||
if staging.exists() and not self._safe_remove_directory(staging):
|
||||
return False
|
||||
|
||||
subpath = skin_info.get('plugin_path')
|
||||
branch_candidates = self._distinct_sequence([
|
||||
branch,
|
||||
skin_info.get('branch'),
|
||||
skin_info.get('default_branch'),
|
||||
skin_info.get('last_commit_branch'),
|
||||
'main',
|
||||
'master'
|
||||
])
|
||||
|
||||
try:
|
||||
branch_used = None
|
||||
if subpath:
|
||||
for candidate in branch_candidates:
|
||||
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
||||
if self._install_from_monorepo(download_url, subpath, staging):
|
||||
branch_used = candidate
|
||||
break
|
||||
else:
|
||||
branch_used = self._install_via_git(repo_url, staging, branch_candidates)
|
||||
if branch_used is None and not staging.exists():
|
||||
for candidate in branch_candidates:
|
||||
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
||||
if self._install_via_download(download_url, staging):
|
||||
branch_used = candidate
|
||||
break
|
||||
|
||||
if branch_used is None and not staging.exists():
|
||||
self.logger.error(f"Failed to install skin {skin_id} via git or archive download")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(staging / 'skin.json', 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
self.logger.error(f"Skin {skin_id} has no valid skin.json: {e}")
|
||||
return False
|
||||
|
||||
missing = [k for k in ('id', 'name', 'version', 'skin_api_version', 'class_name')
|
||||
if not manifest.get(k)]
|
||||
if missing:
|
||||
self.logger.error(f"Skin {skin_id} manifest missing fields: {missing}")
|
||||
return False
|
||||
|
||||
# Unlike plugins, a mismatched id is rejected rather than
|
||||
# renamed: the manifest id is external input, and the registry
|
||||
# id is what the user asked to install.
|
||||
if manifest['id'] != skin_id:
|
||||
self.logger.error(
|
||||
f"Skin manifest id {manifest['id']!r} doesn't match registry id "
|
||||
f"{skin_id!r}; not installing")
|
||||
return False
|
||||
|
||||
def _api_major(v):
|
||||
try:
|
||||
return int(str(v).split('.')[0])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
if _api_major(manifest['skin_api_version']) != _api_major(SKIN_API_VERSION):
|
||||
self.logger.error(
|
||||
f"Skin {skin_id} targets skin API {manifest['skin_api_version']} but this "
|
||||
f"LEDMatrix provides {SKIN_API_VERSION}; not installing")
|
||||
return False
|
||||
|
||||
# Validated — swap into place
|
||||
if target.exists() and not self._safe_remove_directory(target):
|
||||
self.logger.error(f"Could not replace existing skin directory: {target}")
|
||||
return False
|
||||
shutil.move(str(staging), str(target))
|
||||
skin_runtime.discover_skins(force_refresh=True)
|
||||
self.logger.info(f"Successfully installed skin: {skin_id} (branch: {branch_used})")
|
||||
return True
|
||||
finally:
|
||||
if staging.exists():
|
||||
self._safe_remove_directory(staging)
|
||||
|
||||
def uninstall_skin(self, skin_id: str) -> bool:
|
||||
"""Remove an installed skin. Plugin configs referencing it keep
|
||||
validating; rendering falls back to the built-in layout."""
|
||||
from src.skin_system import skin_runtime
|
||||
|
||||
target = self._resolve_skin_target(skin_id)
|
||||
if target is None:
|
||||
return False
|
||||
if not target.exists():
|
||||
self.logger.info(f"Skin {skin_id} not found (already uninstalled)")
|
||||
return True
|
||||
if self._safe_remove_directory(target):
|
||||
skin_runtime.discover_skins(force_refresh=True)
|
||||
self.logger.info(f"Successfully uninstalled skin: {skin_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def uninstall_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Uninstall a plugin by removing its directory.
|
||||
@@ -2638,12 +2495,6 @@ class PluginStoreManager:
|
||||
plugin_path = self._find_plugin_path(plugin_id)
|
||||
|
||||
if plugin_path is None or not plugin_path.exists():
|
||||
# A skin id passed to the plugin uninstall path (the store UI
|
||||
# uses one uninstall flow) removes the skin instead
|
||||
skin_target = self._resolve_skin_target(plugin_id) \
|
||||
if self._SKIN_ID_PATTERN.match(str(plugin_id)) else None
|
||||
if skin_target is not None and skin_target.exists():
|
||||
return self.uninstall_skin(plugin_id)
|
||||
self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)")
|
||||
return True # Already uninstalled, consider this success
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""
|
||||
Skin system: user-installable visual overlays for sports scoreboards.
|
||||
|
||||
A skin replaces only the rendering of a scoreboard (live / recent /
|
||||
upcoming) while the host plugin keeps doing data fetching, scheduling,
|
||||
caching, live priority, and vegas mode. See docs/SKIN_SYSTEM.md.
|
||||
"""
|
||||
|
||||
# Skins are not offered to users yet. The only render hook is
|
||||
# SportsCore._render_game in src/base_classes/sports/core.py, and none of the
|
||||
# current scoreboard plugins (monorepo or third-party) build on
|
||||
# src.base_classes, so a selected skin never draws. The web UI and store
|
||||
# read these instead of offering install/selection; stored "skin" config
|
||||
# values still load and save. See docs/SKIN_SYSTEM.md.
|
||||
SKINS_RENDER_SUPPORTED = False
|
||||
SKINS_UNSUPPORTED_MESSAGE = (
|
||||
"Skins aren't supported yet: the current scoreboard plugins don't render "
|
||||
"them. Installed skins and saved skin settings are kept but have no effect."
|
||||
)
|
||||
|
||||
from src.skin_system.skin_base import ( # noqa: E402
|
||||
SKIN_API_VERSION,
|
||||
VIEW_MODEL_VERSION,
|
||||
ScoreboardSkin,
|
||||
SkinContext,
|
||||
)
|
||||
from src.skin_system.skin_runtime import (
|
||||
build_context,
|
||||
discover_skins,
|
||||
get_skins_directory,
|
||||
load_skin,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SKIN_API_VERSION",
|
||||
"VIEW_MODEL_VERSION",
|
||||
"ScoreboardSkin",
|
||||
"SkinContext",
|
||||
"build_context",
|
||||
"discover_skins",
|
||||
"get_skins_directory",
|
||||
"load_skin",
|
||||
]
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Bot 7th",
|
||||
"is_live": true,
|
||||
"is_final": false,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "LAD",
|
||||
"home_id": "19",
|
||||
"home_score": "5",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "SF",
|
||||
"away_id": "26",
|
||||
"away_score": "3",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"status": "STATUS_IN_PROGRESS",
|
||||
"status_state": "in",
|
||||
"inning": 7,
|
||||
"inning_half": "bottom",
|
||||
"balls": 3,
|
||||
"strikes": 2,
|
||||
"outs": 2,
|
||||
"bases_occupied": [
|
||||
true,
|
||||
true,
|
||||
true
|
||||
],
|
||||
"start_time": "2026-07-16T23:05:00Z",
|
||||
"series_summary": "LAD leads 2-1"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Final",
|
||||
"is_live": false,
|
||||
"is_final": true,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "LAD",
|
||||
"home_id": "19",
|
||||
"home_score": "5",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "SF",
|
||||
"away_id": "26",
|
||||
"away_score": "3",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"status": "STATUS_FINAL",
|
||||
"status_state": "post",
|
||||
"inning": 9,
|
||||
"inning_half": "top",
|
||||
"balls": 0,
|
||||
"strikes": 0,
|
||||
"outs": 3,
|
||||
"bases_occupied": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"start_time": "2026-07-16T23:05:00Z",
|
||||
"series_summary": "Series tied 2-2"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "7:05 PM",
|
||||
"is_live": false,
|
||||
"is_final": false,
|
||||
"is_upcoming": true,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "LAD",
|
||||
"home_id": "19",
|
||||
"home_score": "0",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "SF",
|
||||
"away_id": "26",
|
||||
"away_score": "0",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"status": "STATUS_SCHEDULED",
|
||||
"status_state": "pre",
|
||||
"inning": 0,
|
||||
"inning_half": "top",
|
||||
"balls": 0,
|
||||
"strikes": 0,
|
||||
"outs": 0,
|
||||
"bases_occupied": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"start_time": "2026-07-16T23:05:00Z",
|
||||
"series_summary": ""
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Q4 2:34",
|
||||
"is_live": true,
|
||||
"is_final": false,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "OKC",
|
||||
"home_id": "19",
|
||||
"home_score": "5",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "",
|
||||
"away_abbr": "MIN",
|
||||
"away_id": "26",
|
||||
"away_score": "3",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "",
|
||||
"is_within_window": true,
|
||||
"period": 4,
|
||||
"period_text": "Q4",
|
||||
"clock": "2:34"
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Final",
|
||||
"is_live": false,
|
||||
"is_final": true,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "OKC",
|
||||
"home_id": "19",
|
||||
"home_score": "5",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "",
|
||||
"away_abbr": "MIN",
|
||||
"away_id": "26",
|
||||
"away_score": "3",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "",
|
||||
"is_within_window": true,
|
||||
"period": 4,
|
||||
"period_text": "Final",
|
||||
"clock": "0:00"
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "7:05 PM",
|
||||
"is_live": false,
|
||||
"is_final": false,
|
||||
"is_upcoming": true,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "OKC",
|
||||
"home_id": "19",
|
||||
"home_score": "0",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "",
|
||||
"away_abbr": "MIN",
|
||||
"away_id": "26",
|
||||
"away_score": "0",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "",
|
||||
"is_within_window": true,
|
||||
"period": 0,
|
||||
"period_text": "",
|
||||
"clock": "0:00"
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Q3 8:12",
|
||||
"is_live": true,
|
||||
"is_final": false,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "KC",
|
||||
"home_id": "19",
|
||||
"home_score": "21",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "BUF",
|
||||
"away_id": "26",
|
||||
"away_score": "17",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 3,
|
||||
"period_text": "Q3",
|
||||
"clock": "8:12",
|
||||
"home_timeouts": 2,
|
||||
"away_timeouts": 3,
|
||||
"down_distance_text": "3rd & 4",
|
||||
"down_distance_text_long": "3rd & 4 at KC 22",
|
||||
"is_redzone": true,
|
||||
"possession": "12",
|
||||
"possession_indicator": "away",
|
||||
"scoring_event": null
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Final",
|
||||
"is_live": false,
|
||||
"is_final": true,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "KC",
|
||||
"home_id": "19",
|
||||
"home_score": "21",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "BUF",
|
||||
"away_id": "26",
|
||||
"away_score": "17",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 4,
|
||||
"period_text": "Final",
|
||||
"clock": "0:00",
|
||||
"home_timeouts": 0,
|
||||
"away_timeouts": 0,
|
||||
"down_distance_text": "",
|
||||
"down_distance_text_long": "",
|
||||
"is_redzone": false,
|
||||
"possession": null,
|
||||
"possession_indicator": null,
|
||||
"scoring_event": null
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "7:05 PM",
|
||||
"is_live": false,
|
||||
"is_final": false,
|
||||
"is_upcoming": true,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "KC",
|
||||
"home_id": "19",
|
||||
"home_score": "0",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "BUF",
|
||||
"away_id": "26",
|
||||
"away_score": "0",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 0,
|
||||
"period_text": "",
|
||||
"clock": "0:00",
|
||||
"home_timeouts": 3,
|
||||
"away_timeouts": 3,
|
||||
"down_distance_text": "",
|
||||
"down_distance_text_long": "",
|
||||
"is_redzone": false,
|
||||
"possession": null,
|
||||
"possession_indicator": null,
|
||||
"scoring_event": null
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "P3 14:55",
|
||||
"is_live": true,
|
||||
"is_final": false,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "COL",
|
||||
"home_id": "19",
|
||||
"home_score": "2",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "VGK",
|
||||
"away_id": "26",
|
||||
"away_score": "2",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 3,
|
||||
"period_text": "P3",
|
||||
"clock": "14:55",
|
||||
"power_play": true,
|
||||
"penalties": [],
|
||||
"home_shots": 27,
|
||||
"away_shots": 31
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Final/OT",
|
||||
"is_live": false,
|
||||
"is_final": true,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "COL",
|
||||
"home_id": "19",
|
||||
"home_score": "3",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "VGK",
|
||||
"away_id": "26",
|
||||
"away_score": "2",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 5,
|
||||
"period_text": "Final/OT",
|
||||
"clock": "0:00",
|
||||
"power_play": false,
|
||||
"penalties": [],
|
||||
"home_shots": 35,
|
||||
"away_shots": 33
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "7:05 PM",
|
||||
"is_live": false,
|
||||
"is_final": false,
|
||||
"is_upcoming": true,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "COL",
|
||||
"home_id": "19",
|
||||
"home_score": "0",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "VGK",
|
||||
"away_id": "26",
|
||||
"away_score": "0",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 0,
|
||||
"period_text": "",
|
||||
"clock": "0:00",
|
||||
"power_play": false,
|
||||
"penalties": [],
|
||||
"home_shots": 0,
|
||||
"away_shots": 0
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 444 B |
Binary file not shown.
|
Before Width: | Height: | Size: 446 B |
@@ -1,171 +0,0 @@
|
||||
"""
|
||||
Skin API: the classes a skin author works with.
|
||||
|
||||
A skin is a directory under skins/<skin-id>/ containing a skin.json
|
||||
manifest and a Python module exposing a ScoreboardSkin subclass. The
|
||||
host (a sports scoreboard's base classes) builds a SkinContext per
|
||||
render and calls render_live / render_recent / render_upcoming with the
|
||||
game view model. The skin draws onto ctx.canvas and returns True; the
|
||||
host composites the canvas onto the display. A skin never talks to the
|
||||
display, the network, or the plugin directly.
|
||||
|
||||
Skin API Version: 1.0.0
|
||||
View Model Version: 1.0
|
||||
"""
|
||||
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, Optional, Tuple, Union
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
try:
|
||||
import freetype
|
||||
except ImportError: # pragma: no cover - freetype ships with the project deps
|
||||
freetype = None
|
||||
|
||||
from src.adaptive_layout import FitResult, LayoutContext, Region
|
||||
|
||||
# Major must match a skin manifest's skin_api_version major or the skin
|
||||
# is refused at load time (renames/removals bump major; additions minor).
|
||||
SKIN_API_VERSION = "1.0.0"
|
||||
|
||||
# Version of the guaranteed `game` dict keys (see docs/CREATING_SKINS.md).
|
||||
VIEW_MODEL_VERSION = "1.0"
|
||||
|
||||
|
||||
def _draw_bdf_text_on(draw: ImageDraw.ImageDraw, text: str, x: int, y: int,
|
||||
color: Tuple[int, int, int], face: Any,
|
||||
clip_w: int, clip_h: int) -> None:
|
||||
"""Render a freetype BDF face glyph-by-glyph onto an arbitrary canvas.
|
||||
|
||||
DisplayManager._draw_bdf_text only draws onto the panel image; skins
|
||||
draw onto their own canvas, so the fitted-font path (fit_text can
|
||||
return freetype faces) needs this standalone equivalent.
|
||||
"""
|
||||
try:
|
||||
ascender_px = face.size.ascender >> 6
|
||||
except Exception:
|
||||
ascender_px = 0
|
||||
baseline_y = y + ascender_px
|
||||
for char in text:
|
||||
face.load_char(char)
|
||||
bitmap = face.glyph.bitmap
|
||||
glyph_left = face.glyph.bitmap_left
|
||||
glyph_top = face.glyph.bitmap_top
|
||||
for i in range(bitmap.rows):
|
||||
for j in range(bitmap.width):
|
||||
byte_index = i * bitmap.pitch + (j // 8)
|
||||
if byte_index < len(bitmap.buffer) and \
|
||||
bitmap.buffer[byte_index] & (1 << (7 - (j % 8))):
|
||||
px = x + glyph_left + j
|
||||
py = baseline_y - glyph_top + i
|
||||
if 0 <= px < clip_w and 0 <= py < clip_h:
|
||||
draw.point((px, py), fill=color)
|
||||
x += face.glyph.advance.x >> 6
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkinContext:
|
||||
"""Everything a skin may touch during one render call.
|
||||
|
||||
The canvas is a fresh RGB image sized to the current display (or
|
||||
vegas card). Draw onto it via the helpers below or raw ``draw``;
|
||||
never call display/update methods — the host composites the canvas.
|
||||
"""
|
||||
|
||||
canvas: Image.Image
|
||||
draw: ImageDraw.ImageDraw
|
||||
layout: LayoutContext
|
||||
width: int
|
||||
height: int
|
||||
fonts: Dict[str, Any]
|
||||
options: Dict[str, Any]
|
||||
logger: Any
|
||||
sport: Optional[str] = None
|
||||
view_model_version: str = VIEW_MODEL_VERSION
|
||||
# load_logo("home") / load_logo("away") -> RGBA PIL image or None.
|
||||
# Bound to the current game; hits the host's logo cache (never loads
|
||||
# from disk twice), downloads missing logos like the built-in layout.
|
||||
load_logo: Callable[[str], Optional[Image.Image]] = field(default=lambda side: None)
|
||||
# draw_text_outlined(text, (x, y), font, fill=..., outline_color=...)
|
||||
# — the classic scorebug outlined text, drawn onto this canvas.
|
||||
# TTF fonts only (ctx.fonts values are TTF); for ladder-fitted fonts
|
||||
# use draw_fit / draw_text, which handle BDF faces too.
|
||||
draw_text_outlined: Callable[..., None] = field(default=lambda *a, **k: None)
|
||||
|
||||
def draw_text(self, text: str, x: int, y: int,
|
||||
color: Tuple[int, int, int] = (255, 255, 255),
|
||||
font: Any = None) -> None:
|
||||
"""Draw text at a top-left position, handling both PIL fonts and
|
||||
the freetype BDF faces that layout.fit_text can return."""
|
||||
if font is None:
|
||||
font = self.fonts.get('time')
|
||||
if freetype is not None and isinstance(font, freetype.Face):
|
||||
_draw_bdf_text_on(self.draw, text, int(x), int(y), color, font,
|
||||
self.width, self.height)
|
||||
else:
|
||||
self.draw.text((int(x), int(y)), text, font=font, fill=color)
|
||||
|
||||
def draw_fit(self, fit: FitResult, box: Union[Region, Tuple[int, int]],
|
||||
color: Tuple[int, int, int] = (255, 255, 255),
|
||||
align: str = "center", valign: str = "center") -> None:
|
||||
"""Draw a layout.fit_text() result aligned within a Region — the
|
||||
canvas-local equivalent of adaptive_layout.draw_fitted_text."""
|
||||
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
||||
x, y = region.align_xy(fit.width, fit.height, align, valign)
|
||||
self.draw_text(fit.text, x, y - fit.y_offset, color=color, font=fit.font)
|
||||
|
||||
def draw_image(self, img: Optional[Image.Image],
|
||||
box: Union[Region, Tuple[int, int]], *,
|
||||
mode: str = "contain", align: str = "center",
|
||||
valign: str = "center", cache_key: Any = None) -> None:
|
||||
"""Fit an image (a logo, art) into a Region and paste it, honoring
|
||||
alpha. Silently no-ops on None so `ctx.draw_image(ctx.load_logo(
|
||||
'home'), ...)` stays safe when a logo is missing."""
|
||||
if img is None:
|
||||
return
|
||||
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
||||
fitted = self.layout.fit_image(img, region, mode=mode,
|
||||
cache_key=cache_key)
|
||||
result = fitted.image # fit_image returns an ImageFitResult (always RGBA)
|
||||
if result is None:
|
||||
return
|
||||
x, y = region.align_xy(result.width, result.height, align, valign)
|
||||
self.canvas.paste(result, (int(x), int(y)), result)
|
||||
|
||||
|
||||
class ScoreboardSkin(ABC):
|
||||
"""Base class for scoreboard skins.
|
||||
|
||||
Override only the modes you want to restyle; any mode you leave
|
||||
unimplemented (or return False from) falls back to the plugin's
|
||||
built-in renderer, so a live-only skin still gets recent/upcoming
|
||||
screens for free.
|
||||
|
||||
Skins should be stateless: three host instances (live, recent,
|
||||
upcoming) each hold their own skin instance, and a render must be
|
||||
derivable from (ctx, game) alone.
|
||||
"""
|
||||
|
||||
SKIN_API_VERSION = SKIN_API_VERSION
|
||||
|
||||
def __init__(self, manifest: Dict[str, Any], options: Dict[str, Any]):
|
||||
self.manifest = manifest
|
||||
self.options = options or {}
|
||||
|
||||
def render_live(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
def render_recent(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
def render_upcoming(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
def render_vegas_card(self, ctx: SkinContext,
|
||||
game: Dict[str, Any]) -> Optional[Image.Image]:
|
||||
"""Render one vegas scroll card at ctx.width x ctx.height. Return
|
||||
the finished image, or None to let the host use its default vegas
|
||||
rendering (which captures the regular display output)."""
|
||||
return None
|
||||
@@ -1,352 +0,0 @@
|
||||
"""
|
||||
Skin runtime: discovery, validation, loading, and context building.
|
||||
|
||||
Deliberately generic — this module knows nothing about sports beyond
|
||||
passing a `sport` label through; the sports flavor lives in skin_base
|
||||
(ScoreboardSkin) and in the hosts that call build_context.
|
||||
|
||||
Every failure path here logs and returns None: a broken or missing skin
|
||||
must never take down the plugin that references it — the host falls
|
||||
back to its built-in renderer.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from src.adaptive_layout import LayoutContext
|
||||
from src.logging_config import get_logger
|
||||
from src.skin_system.skin_base import (
|
||||
SKIN_API_VERSION,
|
||||
ScoreboardSkin,
|
||||
SkinContext,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_REQUIRED_MANIFEST_FIELDS = ("id", "name", "version", "skin_api_version", "class_name")
|
||||
_DEFAULT_ENTRY_POINT = "skin.py"
|
||||
|
||||
_lock = threading.RLock()
|
||||
# skins_dir -> (fingerprint, {skin_id: manifest+path})
|
||||
_discovery_cache: Dict[str, Tuple[Tuple, Dict[str, Dict[str, Any]]]] = {}
|
||||
|
||||
_shared_layout_font_manager: Optional[Any] = None
|
||||
|
||||
|
||||
def _get_font_manager() -> Any:
|
||||
"""Shared FontManager for skin LayoutContexts. SportsCore hosts don't
|
||||
carry a plugin_manager, so skins share one module-level FontManager —
|
||||
the same shape as base_plugin._fallback_font_manager, constructed
|
||||
directly so rendering never has to import the whole plugin system."""
|
||||
global _shared_layout_font_manager
|
||||
if _shared_layout_font_manager is None:
|
||||
from src.font_manager import FontManager
|
||||
_shared_layout_font_manager = FontManager({})
|
||||
return _shared_layout_font_manager
|
||||
|
||||
|
||||
def get_skins_directory() -> Path:
|
||||
"""Central skins directory: <project_root>/skins. Lives outside the
|
||||
plugin directories on purpose — plugin reinstall/update deletes the
|
||||
whole plugin directory, and a skin must survive that."""
|
||||
return Path(__file__).resolve().parents[2] / "skins"
|
||||
|
||||
|
||||
def _major(version: str) -> Optional[int]:
|
||||
try:
|
||||
return int(str(version).split(".")[0])
|
||||
except (ValueError, AttributeError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _read_manifest(skin_dir: Path) -> Optional[Dict[str, Any]]:
|
||||
manifest_path = skin_dir / "skin.json"
|
||||
if not manifest_path.is_file():
|
||||
return None
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.error("Skin manifest %s is unreadable: %s", manifest_path, e)
|
||||
return None
|
||||
missing = [k for k in _REQUIRED_MANIFEST_FIELDS if not manifest.get(k)]
|
||||
if missing:
|
||||
logger.error("Skin manifest %s missing required fields: %s",
|
||||
manifest_path, ", ".join(missing))
|
||||
return None
|
||||
if manifest["id"] != skin_dir.name:
|
||||
logger.warning("Skin manifest id %r does not match directory name %r",
|
||||
manifest["id"], skin_dir.name)
|
||||
manifest["_skin_dir"] = str(skin_dir)
|
||||
return manifest
|
||||
|
||||
|
||||
def _discovery_fingerprint(skins_dir: Path) -> Optional[Tuple]:
|
||||
"""Cache key for a skins directory: its mtime plus every skin.json's
|
||||
(path, mtime). The directory mtime alone misses in-place manifest edits
|
||||
(a skin updated without adding/removing entries)."""
|
||||
try:
|
||||
parts = [skins_dir.stat().st_mtime]
|
||||
for manifest_path in sorted(skins_dir.glob("*/skin.json")):
|
||||
parts.append((str(manifest_path), manifest_path.stat().st_mtime))
|
||||
return tuple(parts)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def discover_skins(skins_dir: Optional[Path] = None,
|
||||
force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:
|
||||
"""Return {skin_id: manifest} for every valid skin package installed.
|
||||
|
||||
Cached per directory and invalidated when the directory or any
|
||||
skin.json changes; pass force_refresh to bypass.
|
||||
"""
|
||||
skins_dir = Path(skins_dir) if skins_dir else get_skins_directory()
|
||||
cache_key = str(skins_dir)
|
||||
fingerprint = _discovery_fingerprint(skins_dir)
|
||||
if fingerprint is None:
|
||||
return {}
|
||||
|
||||
with _lock:
|
||||
cached = _discovery_cache.get(cache_key)
|
||||
if cached and not force_refresh and cached[0] == fingerprint:
|
||||
return dict(cached[1])
|
||||
|
||||
skins: Dict[str, Dict[str, Any]] = {}
|
||||
for entry in sorted(skins_dir.iterdir()):
|
||||
if not entry.is_dir() or entry.name.startswith((".", "_")):
|
||||
continue
|
||||
manifest = _read_manifest(entry)
|
||||
if manifest:
|
||||
skins[manifest["id"]] = manifest
|
||||
_discovery_cache[cache_key] = (fingerprint, skins)
|
||||
return dict(skins)
|
||||
|
||||
|
||||
def skin_targets(manifest: Dict[str, Any]) -> Tuple[list, list]:
|
||||
"""(sports, sport_keys) a skin declares it supports."""
|
||||
targets = manifest.get("targets") or {}
|
||||
return (list(targets.get("sports") or []),
|
||||
list(targets.get("sport_keys") or []))
|
||||
|
||||
|
||||
def skin_matches_target(manifest: Dict[str, Any], sport: Optional[str],
|
||||
sport_key: Optional[str]) -> bool:
|
||||
"""True when the skin declares support for this sport family or exact
|
||||
sport key. A skin with no targets at all matches everything."""
|
||||
sports, sport_keys = skin_targets(manifest)
|
||||
if not sports and not sport_keys:
|
||||
return True
|
||||
if sport and sport in sports:
|
||||
return True
|
||||
if sport_key and sport_key in sport_keys:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def skins_for_plugin(plugin_id: str,
|
||||
skins: Optional[Dict[str, Dict[str, Any]]] = None) -> Dict[str, Dict[str, Any]]:
|
||||
"""Installed skins that plausibly apply to a plugin, for UI dropdowns.
|
||||
|
||||
A skin matches when the plugin id is listed in targets.plugins, or any
|
||||
declared sport / sport_key appears as a token of the plugin id (so a
|
||||
skin targeting sports=["baseball"] matches "baseball-scoreboard", and
|
||||
sport_keys=["milb"] matches "milb-scoreboard")."""
|
||||
if skins is None:
|
||||
skins = discover_skins()
|
||||
tokens = set(str(plugin_id).lower().replace("-", "_").split("_"))
|
||||
matched = {}
|
||||
for skin_id, manifest in skins.items():
|
||||
targets = manifest.get("targets") or {}
|
||||
if plugin_id in (targets.get("plugins") or []):
|
||||
matched[skin_id] = manifest
|
||||
continue
|
||||
sports, sport_keys = skin_targets(manifest)
|
||||
if any(str(t).lower() in tokens for t in sports + sport_keys):
|
||||
matched[skin_id] = manifest
|
||||
return matched
|
||||
|
||||
|
||||
def _load_skin_module(skin_id: str, skin_dir: Path, entry_point: str) -> Optional[Any]:
|
||||
"""Import the skin's entry module under a namespaced sys.modules key,
|
||||
namespacing its sibling .py files the same way — the collision-
|
||||
avoidance scheme plugins use (plugin_loader._namespace_plugin_modules),
|
||||
so two skins can both ship a helpers.py.
|
||||
|
||||
The entry module is cached: the live/recent/upcoming hosts all load
|
||||
the same skin, and only the first load executes any code. (A skin
|
||||
whose *code* changed on disk needs a service restart to take effect —
|
||||
Python modules can't be safely hot-swapped.)
|
||||
"""
|
||||
entry_path = skin_dir / entry_point
|
||||
if not entry_path.is_file():
|
||||
logger.error("Skin '%s' entry point not found: %s", skin_id, entry_path)
|
||||
return None
|
||||
|
||||
module_name = f"_skin_{skin_id}_{Path(entry_point).stem}"
|
||||
with _lock:
|
||||
cached_entry = sys.modules.get(module_name)
|
||||
if cached_entry is not None:
|
||||
return cached_entry
|
||||
|
||||
# Import siblings under their namespaced alias, and *bind* the bare
|
||||
# name (cached or fresh) so `import helpers` inside the entry module
|
||||
# resolves to this skin's copy. The bare bindings are transient —
|
||||
# restored below so another skin's identically-named sibling can't
|
||||
# be shadowed by ours.
|
||||
replaced_bare: Dict[str, Any] = {}
|
||||
try:
|
||||
for sibling in skin_dir.glob("*.py"):
|
||||
if sibling.name == entry_point:
|
||||
continue
|
||||
alias = f"_skin_{skin_id}_{sibling.stem}"
|
||||
module = sys.modules.get(alias)
|
||||
if module is None:
|
||||
spec = importlib.util.spec_from_file_location(alias, sibling)
|
||||
if not spec or not spec.loader:
|
||||
continue
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[alias] = module
|
||||
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
|
||||
sys.modules[sibling.stem] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as e:
|
||||
logger.error("Skin '%s' sibling module %s failed to import: %s",
|
||||
skin_id, sibling.name, e, exc_info=True)
|
||||
sys.modules.pop(alias, None)
|
||||
return None
|
||||
else:
|
||||
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
|
||||
sys.modules[sibling.stem] = module
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, entry_path)
|
||||
if not spec or not spec.loader:
|
||||
logger.error("Skin '%s': could not create import spec for %s",
|
||||
skin_id, entry_path)
|
||||
return None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
except Exception as e:
|
||||
sys.modules.pop(module_name, None)
|
||||
logger.error("Skin '%s' failed to import: %s", skin_id, e, exc_info=True)
|
||||
return None
|
||||
finally:
|
||||
for bare_name, previous in replaced_bare.items():
|
||||
if previous is None:
|
||||
sys.modules.pop(bare_name, None)
|
||||
else:
|
||||
sys.modules[bare_name] = previous
|
||||
|
||||
|
||||
def load_skin(skin_id: str, sport: Optional[str] = None,
|
||||
sport_key: Optional[str] = None,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
skins_dir: Optional[Path] = None) -> Optional[ScoreboardSkin]:
|
||||
"""Load and instantiate a skin. Returns None (after logging why) on
|
||||
any failure — callers treat None as 'use the built-in renderer'."""
|
||||
skins = discover_skins(skins_dir)
|
||||
manifest = skins.get(skin_id)
|
||||
if manifest is None:
|
||||
logger.warning("Skin '%s' is configured but not installed under %s; "
|
||||
"using built-in renderer",
|
||||
skin_id, skins_dir or get_skins_directory())
|
||||
return None
|
||||
|
||||
manifest_major = _major(manifest.get("skin_api_version"))
|
||||
api_major = _major(SKIN_API_VERSION)
|
||||
if manifest_major != api_major:
|
||||
logger.error("Skin '%s' targets skin API %s but this LEDMatrix "
|
||||
"provides %s — the skin needs an update; using "
|
||||
"built-in renderer",
|
||||
skin_id, manifest.get("skin_api_version"), SKIN_API_VERSION)
|
||||
return None
|
||||
|
||||
if not skin_matches_target(manifest, sport, sport_key):
|
||||
# Soft: the user explicitly configured it, so warn but load anyway
|
||||
# (a baseball skin may render an acceptable generic scoreboard).
|
||||
logger.warning("Skin '%s' does not declare support for sport=%r / "
|
||||
"sport_key=%r; loading anyway", skin_id, sport, sport_key)
|
||||
|
||||
skin_dir = Path(manifest["_skin_dir"])
|
||||
module = _load_skin_module(skin_id, skin_dir,
|
||||
manifest.get("entry_point", _DEFAULT_ENTRY_POINT))
|
||||
if module is None:
|
||||
return None
|
||||
|
||||
class_name = manifest["class_name"]
|
||||
skin_class = getattr(module, class_name, None)
|
||||
if skin_class is None or not isinstance(skin_class, type) or \
|
||||
not issubclass(skin_class, ScoreboardSkin):
|
||||
logger.error("Skin '%s': %s is missing or not a ScoreboardSkin subclass",
|
||||
skin_id, class_name)
|
||||
return None
|
||||
|
||||
try:
|
||||
return skin_class(manifest, options or {})
|
||||
except Exception as e:
|
||||
logger.error("Skin '%s' failed to instantiate: %s", skin_id, e, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def build_context(host: Any, game: Dict[str, Any],
|
||||
size: Optional[Tuple[int, int]] = None) -> SkinContext:
|
||||
"""Build a SkinContext for one render call.
|
||||
|
||||
`host` is a SportsCore-style object: display_manager, fonts, logger,
|
||||
sport, skin_options, _load_and_resize_logo, _draw_text_with_outline.
|
||||
`size` overrides the canvas size (vegas cards); default is the
|
||||
current display size read live from the display manager.
|
||||
"""
|
||||
if size is not None:
|
||||
width, height = int(size[0]), int(size[1])
|
||||
else:
|
||||
dm = host.display_manager
|
||||
width = getattr(dm, "width", None) or dm.matrix.width
|
||||
height = getattr(dm, "height", None) or dm.matrix.height
|
||||
|
||||
canvas = Image.new("RGB", (width, height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
layout = LayoutContext(width, height, _get_font_manager())
|
||||
|
||||
def load_logo(side: str) -> Optional[Image.Image]:
|
||||
if side not in ("home", "away"):
|
||||
return None
|
||||
try:
|
||||
logo_path = game.get(f"{side}_logo_path")
|
||||
if logo_path is not None and not isinstance(logo_path, Path):
|
||||
logo_path = Path(logo_path)
|
||||
return host._load_and_resize_logo(
|
||||
game.get(f"{side}_id"), game.get(f"{side}_abbr"),
|
||||
logo_path, game.get(f"{side}_logo_url"))
|
||||
except Exception as e:
|
||||
host.logger.warning("Skin logo load failed for %s: %s", side, e)
|
||||
return None
|
||||
|
||||
def draw_text_outlined(text, position, font, fill=(255, 255, 255),
|
||||
outline_color=(0, 0, 0)):
|
||||
host._draw_text_with_outline(draw, text, position, font,
|
||||
fill=fill, outline_color=outline_color)
|
||||
|
||||
return SkinContext(
|
||||
canvas=canvas,
|
||||
draw=draw,
|
||||
layout=layout,
|
||||
width=width,
|
||||
height=height,
|
||||
fonts=dict(host.fonts),
|
||||
options=dict(getattr(host, "skin_options", {}) or {}),
|
||||
logger=host.logger,
|
||||
sport=getattr(host, "sport", None),
|
||||
load_logo=load_logo,
|
||||
draw_text_outlined=draw_text_outlined,
|
||||
)
|
||||
Reference in New Issue
Block a user