-
-
Notifications
You must be signed in to change notification settings - Fork 5
Sports scoreboard parity — SportsCore for baseball, fixes across all 5 plugins #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
ba26001
feat(baseball): add full scorebug rendering with baseball-specific el…
ab3b842
fix(baseball): fix inning display, count fallback, and records config
5db7e61
feat(baseball): integrate odds rendering, fix scroll logo paths
b757986
feat(baseball): integrate logo manager and rankings manager
aaff818
fix(baseball): move odds fetch to update cycle, fix pick'em line and …
aaa4a17
fix(baseball): thread-safe rankings, close logo file handles, Pillow …
cb37884
feat: sports scoreboard parity — SportsCore for baseball, fixes acros…
f04e626
Merge main into feat/baseball-scorebug-rendering
f84c9ee
fix: address code review — cache TTL, finish detection, state leak, a…
f861ba0
fix: address PR nitpicks — remove duplicates, unused params, cleanup
3949a95
fix: minor code review fixes — len(None) crash, error-path rendering,…
9c903bc
fix: odds sentinel leak, stale game detection, duration bounds, and c…
12809e3
fix: isinstance dispatch, ClassVar annotations, MiLB date-range optim…
8fc1590
fix: lock _detect_stale_games mutation, normalize EN DASH in comments
7ec04b1
fix: stale game detection in soccer and baseball — operate on self.li…
ba65913
fix: address PR review nitpicks across all 5 scoreboard plugins
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,294 @@ | ||
| """ | ||
| BaseOddsManager - Base class for odds data fetching and management. | ||
|
|
||
| This base class provides core odds fetching functionality that can be inherited | ||
| by plugins that need odds data (odds ticker, scoreboards, etc.). | ||
|
|
||
| Follows LEDMatrix configuration management patterns: | ||
| - Single responsibility: Data fetching only | ||
| - Reusable: Other plugins can inherit from it | ||
| - Clean configuration: Separate config sections | ||
| - Maintainable: Changes to odds logic affect all plugins | ||
| """ | ||
|
|
||
| import time | ||
| import logging | ||
| import requests | ||
| import json | ||
| from datetime import datetime, timedelta, timezone | ||
| from typing import Dict, Any, Optional, List | ||
| import pytz | ||
|
|
||
| # Import the API counter function from web interface | ||
| try: | ||
| from web_interface_v2 import increment_api_counter | ||
| except ImportError: | ||
| # Fallback if web interface is not available | ||
| def increment_api_counter(kind: str, count: int = 1): | ||
| pass | ||
|
|
||
|
|
||
| class BaseOddsManager: | ||
| """ | ||
| Base class for odds data fetching and management. | ||
|
|
||
| Provides core functionality for: | ||
| - ESPN API odds fetching | ||
| - Caching and data processing | ||
| - Error handling and timeouts | ||
| - League mapping and data extraction | ||
|
|
||
| Plugins can inherit from this class to get odds functionality. | ||
| """ | ||
|
|
||
| def __init__(self, cache_manager, config_manager=None): | ||
| """ | ||
| Initialize the base odds manager. | ||
|
|
||
| Args: | ||
| cache_manager: Cache manager instance for data persistence | ||
| config_manager: Configuration manager (optional) | ||
| """ | ||
| self.cache_manager = cache_manager | ||
| self.config_manager = config_manager | ||
| self.logger = logging.getLogger(__name__) | ||
| self.base_url = "https://sports.core.api.espn.com/v2/sports" | ||
|
|
||
| # Configuration with defaults | ||
| self.update_interval = 3600 # 1 hour default | ||
| self.request_timeout = 30 # 30 seconds default | ||
|
|
||
| # Load configuration if available | ||
| if config_manager: | ||
| self._load_configuration() | ||
|
|
||
| def _load_configuration(self): | ||
| """Load configuration from config manager.""" | ||
| if not self.config_manager: | ||
| return | ||
|
|
||
| try: | ||
| config = self.config_manager.get_config() | ||
| odds_config = config.get("base_odds_manager", {}) | ||
|
|
||
| self.update_interval = odds_config.get( | ||
| "update_interval", self.update_interval | ||
| ) | ||
| self.request_timeout = odds_config.get("timeout", self.request_timeout) | ||
|
|
||
| self.logger.debug( | ||
| f"BaseOddsManager configuration loaded: " | ||
| f"update_interval={self.update_interval}s, " | ||
| f"timeout={self.request_timeout}s" | ||
| ) | ||
|
|
||
| except Exception as e: | ||
| self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}") | ||
|
|
||
| def get_odds( | ||
| self, | ||
| sport: str | None, | ||
| league: str | None, | ||
| event_id: str, | ||
| update_interval_seconds: Optional[int] = None, | ||
| ) -> Optional[Dict[str, Any]]: | ||
| """ | ||
| Fetch odds data for a specific game. | ||
|
|
||
| Args: | ||
| sport: Sport name (e.g., 'football', 'basketball') | ||
| league: League name (e.g., 'nfl', 'nba') | ||
| event_id: ESPN event ID | ||
| update_interval_seconds: Override default update interval | ||
|
|
||
| Returns: | ||
| Dictionary containing odds data or None if unavailable | ||
| """ | ||
| if sport is None or league is None: | ||
| raise ValueError("Sport and League cannot be None") | ||
|
|
||
| # Use provided interval or default | ||
| interval = update_interval_seconds or self.update_interval | ||
| cache_key = f"odds_espn_{sport}_{league}_{event_id}" | ||
|
|
||
| # Check cache first | ||
| cached_data = self.cache_manager.get(cache_key) | ||
|
|
||
| if cached_data: | ||
| # Filter out the "no_odds" marker - it should not be returned | ||
| # as valid odds data. Treat it as a cache miss so a fresh API | ||
| # call is made once the cache entry expires. | ||
| if isinstance(cached_data, dict) and cached_data.get("no_odds"): | ||
| self.logger.debug(f"Cached no-odds marker for {cache_key}, skipping") | ||
| else: | ||
| self.logger.info(f"Using cached odds from ESPN for {cache_key}") | ||
| return cached_data | ||
|
|
||
| self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}") | ||
|
|
||
| try: | ||
| # Map league names to ESPN API format | ||
| league_mapping = { | ||
| "mlb": "mlb", | ||
| "college-baseball": "college-baseball", | ||
| "ncaa_fb": "college-football", | ||
| "nfl": "nfl", | ||
| "nba": "nba", | ||
| "nhl": "nhl", | ||
| } | ||
|
|
||
| espn_league = league_mapping.get(league, league) | ||
| url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds" | ||
| self.logger.info(f"Requesting odds from URL: {url}") | ||
|
|
||
| response = requests.get(url, timeout=self.request_timeout) | ||
| response.raise_for_status() | ||
| raw_data = response.json() | ||
|
|
||
| # Increment API counter for odds data | ||
| increment_api_counter("odds", 1) | ||
| self.logger.debug( | ||
| f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}" | ||
| ) | ||
|
|
||
| odds_data = self._extract_espn_data(raw_data) | ||
| if odds_data: | ||
| self.logger.info(f"Successfully extracted odds data: {odds_data}") | ||
| else: | ||
| self.logger.debug("No odds data available for this game") | ||
|
|
||
| if odds_data: | ||
| self.cache_manager.set(cache_key, odds_data, ttl=interval) | ||
| self.logger.info(f"Saved odds data to cache for {cache_key}") | ||
| else: | ||
| self.logger.debug(f"No odds data available for {cache_key}") | ||
| # Cache the fact that no odds are available to avoid repeated API calls | ||
| self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval) | ||
|
|
||
| return odds_data | ||
|
|
||
| except requests.exceptions.RequestException as e: | ||
| self.logger.exception(f"Error fetching odds from ESPN API for {cache_key}") | ||
| except json.JSONDecodeError: | ||
| self.logger.exception( | ||
| f"Error decoding JSON response from ESPN API for {cache_key}" | ||
| ) | ||
|
|
||
| # Return cached odds on error, but filter out the no_odds sentinel | ||
| cached = self.cache_manager.get(cache_key) | ||
| if isinstance(cached, dict) and cached.get("no_odds"): | ||
| return None | ||
| return cached | ||
|
|
||
| def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: | ||
| """ | ||
| Extract and format odds data from ESPN API response. | ||
|
|
||
| Args: | ||
| data: Raw ESPN API response data | ||
|
|
||
| Returns: | ||
| Formatted odds data dictionary or None | ||
| """ | ||
| self.logger.debug(f"Extracting ESPN odds data. Data keys: {list(data.keys())}") | ||
|
|
||
| if "items" in data and data["items"]: | ||
| self.logger.debug(f"Found {len(data['items'])} items in odds data") | ||
| item = data["items"][0] | ||
| self.logger.debug(f"First item keys: {list(item.keys())}") | ||
|
|
||
| # The ESPN API returns odds data directly in the item, not in a providers array | ||
| # Extract the odds data directly from the item | ||
| extracted_data = { | ||
| "details": item.get("details"), | ||
| "over_under": item.get("overUnder"), | ||
| "spread": item.get("spread"), | ||
| "home_team_odds": { | ||
| "money_line": item.get("homeTeamOdds", {}).get("moneyLine"), | ||
| "spread_odds": item.get("homeTeamOdds", {}) | ||
| .get("current", {}) | ||
| .get("pointSpread", {}) | ||
| .get("value"), | ||
| }, | ||
| "away_team_odds": { | ||
| "money_line": item.get("awayTeamOdds", {}).get("moneyLine"), | ||
| "spread_odds": item.get("awayTeamOdds", {}) | ||
| .get("current", {}) | ||
| .get("pointSpread", {}) | ||
| .get("value"), | ||
| }, | ||
| } | ||
| self.logger.debug( | ||
| f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}" | ||
| ) | ||
| return extracted_data | ||
|
|
||
| # Check if this is a valid empty response or an unexpected structure | ||
| if ( | ||
| "count" in data | ||
| and data["count"] == 0 | ||
| and "items" in data | ||
| and data["items"] == [] | ||
| ): | ||
| # This is a valid empty response - no odds available for this game | ||
| self.logger.debug("Valid empty response - no odds available for this game") | ||
| return None | ||
|
|
||
| # Unexpected structure | ||
| self.logger.warning( | ||
| f"Unexpected odds data structure: {json.dumps(data, indent=2)}" | ||
| ) | ||
| return None | ||
|
|
||
| def get_multiple_odds( | ||
| self, | ||
| sport: str, | ||
| league: str, | ||
| event_ids: List[str], | ||
| update_interval_seconds: Optional[int] = None, | ||
| ) -> Dict[str, Dict[str, Any]]: | ||
| """ | ||
| Fetch odds data for multiple games. | ||
|
|
||
| Args: | ||
| sport: Sport name | ||
| league: League name | ||
| event_ids: List of ESPN event IDs | ||
| update_interval_seconds: Override default update interval | ||
|
|
||
| Returns: | ||
| Dictionary mapping event_id to odds data | ||
| """ | ||
| results = {} | ||
|
|
||
| for event_id in event_ids: | ||
| try: | ||
| odds_data = self.get_odds( | ||
| sport, league, event_id, update_interval_seconds | ||
| ) | ||
| if odds_data: | ||
| results[event_id] = odds_data | ||
| except Exception as e: | ||
| self.logger.error(f"Error fetching odds for event {event_id}: {e}") | ||
| continue | ||
|
|
||
| return results | ||
|
|
||
| def clear_cache(self, sport: Optional[str] = None, league: Optional[str] = None, event_id: Optional[str] = None): | ||
| """ | ||
| Clear odds cache for specific criteria. | ||
|
|
||
| Args: | ||
| sport: Sport name (optional) | ||
| league: League name (optional) | ||
| event_id: Event ID (optional) | ||
| """ | ||
| if sport and league and event_id: | ||
| # Clear specific event | ||
| cache_key = f"odds_espn_{sport}_{league}_{event_id}" | ||
| self.cache_manager.clear_cache(cache_key) | ||
| self.logger.info(f"Cleared cache for {cache_key}") | ||
| else: | ||
| # Clear all odds cache | ||
| self.cache_manager.clear_cache() | ||
| self.logger.info("Cleared all cache") | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 2945
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 1712
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 2981
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 834
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 9006
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 891
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 440
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 3979
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 483
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 899
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 55
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 55
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 97
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 55
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 7595
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 363
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 3821
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 2298
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 55
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 1225
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 3331
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 275
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 3530
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 909
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 743
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 595
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 597
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 55
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 2491
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 55
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 771
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 490
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 76
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 152
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 3079
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 891
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 521
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 837
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 657
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 384
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 753
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 55
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 55
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 55
🏁 Script executed:
Repository: ChuckBuilds/ledmatrix-plugins
Length of output: 4895
Pass
ttlparameter toset()calls; theintervalvariable is unused but should be applied as cache TTL.The
intervalcomputed on line 114 is never used, andset()calls (lines 164, 169) don't pass a TTL value, so configured cache expiration is ignored. Useinterval(orself.cache_ttl) as the TTL when caching.The
delete()method is correct—do not change it toclear_cache()as that method does not exist on the cache manager.🔧 Suggested fix
Also applies to: 276-292
🧰 Tools
🪛 Ruff (0.15.0)
[error] 114-114: Local variable
intervalis assigned to but never usedRemove assignment to unused variable
interval(F841)
[warning] 121-121: Comment contains ambiguous
–(EN DASH). Did you mean-(HYPHEN-MINUS)?(RUF003)
🤖 Prompt for AI Agents