Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions plugins.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"last_updated": "2026-02-13",
"last_updated": "2026-02-14",
"plugins": [
{
"id": "hello-world",
Expand Down Expand Up @@ -196,10 +196,10 @@
"plugin_path": "plugins/hockey-scoreboard",
"stars": 0,
"downloads": 0,
"last_updated": "2026-02-11",
"last_updated": "2026-02-14",
"verified": true,
"screenshot": "",
"latest_version": "1.0.8"
"latest_version": "1.1.0"
},
{
"id": "football-scoreboard",
Expand All @@ -221,10 +221,10 @@
"plugin_path": "plugins/football-scoreboard",
"stars": 0,
"downloads": 0,
"last_updated": "2025-11-05",
"last_updated": "2026-02-14",
"verified": true,
"screenshot": "",
"latest_version": "2.0.7"
"latest_version": "2.1.0"
},
{
"id": "ufc-scoreboard",
Expand Down Expand Up @@ -270,10 +270,10 @@
"plugin_path": "plugins/basketball-scoreboard",
"stars": 0,
"downloads": 0,
"last_updated": "2026-02-13",
"last_updated": "2026-02-14",
"verified": true,
"screenshot": "",
"latest_version": "1.0.6"
"latest_version": "1.1.0"
},
{
"id": "baseball-scoreboard",
Expand All @@ -299,7 +299,7 @@
"last_updated": "2026-02-14",
"verified": true,
"screenshot": "",
"latest_version": "1.3.0"
"latest_version": "2.0.0"
},
{
"id": "soccer-scoreboard",
Expand All @@ -325,10 +325,10 @@
"plugin_path": "plugins/soccer-scoreboard",
"stars": 0,
"downloads": 0,
"last_updated": "2026-02-11",
"last_updated": "2026-02-14",
"verified": true,
"screenshot": "",
"latest_version": "1.1.0"
"latest_version": "1.3.0"
},
{
"id": "odds-ticker",
Expand Down
294 changes: 294 additions & 0 deletions plugins/baseball-scoreboard/base_odds_manager.py
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)

Comment on lines +110 to +167
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find the cache manager class definition
rg -n "class.*Cache" --type py -A 10

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 2945


🏁 Script executed:

# Search for cache manager instantiation and import in the target file
rg -n "cache_manager|CacheManager" plugins/baseball-scoreboard/base_odds_manager.py -B 2 -A 2

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 1712


🏁 Script executed:

# Look at the specific lines mentioned in the review (113-170 and 276-292)
sed -n '113,170p' plugins/baseball-scoreboard/base_odds_manager.py | cat -n

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 2981


🏁 Script executed:

# Check lines 276-292
sed -n '276,292p' plugins/baseball-scoreboard/base_odds_manager.py | cat -n

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 834


🏁 Script executed:

# Find where delete() or clear_cache() is used or defined
rg -n "def (delete|clear_cache|get|set)\(" --type py -B 1 -A 5

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 9006


🏁 Script executed:

# Find the cache manager class used specifically in baseball-scoreboard
find plugins/baseball-scoreboard -name "*.py" -type f | head -20

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 891


🏁 Script executed:

# Search for cache manager class or import in baseball-scoreboard directory
rg -n "class.*Cache|import.*[Cc]ache" plugins/baseball-scoreboard/ -A 3

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 440


🏁 Script executed:

# Look at the initialization of base_odds_manager to understand the cache manager
sed -n '1,100p' plugins/baseball-scoreboard/base_odds_manager.py | cat -n

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 3979


🏁 Script executed:

# Check if there's a cache manager module in baseball-scoreboard or parent
find plugins/baseball-scoreboard -name "*cache*" -o -name "*manager*" | grep -v __pycache__

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 483


🏁 Script executed:

# Look at the full clear_cache method to understand delete vs clear
sed -n '275,295p' plugins/baseball-scoreboard/base_odds_manager.py | cat -n

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 899


🏁 Script executed:

# Find the cache_manager module in the project
find . -path ./node_modules -prune -o -name "cache_manager.py" -type f -print

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 55


🏁 Script executed:

# Search for CacheManager class definition more broadly
rg -n "class CacheManager" --type py -A 20

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 55


🏁 Script executed:

# Check if there's a src directory at project root
ls -la src/ 2>/dev/null || echo "No src directory at root"

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 97


🏁 Script executed:

# Look for cache manager in common locations
fd -t f "cache" -e py | head -20

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 55


🏁 Script executed:

# Search for all files importing or defining CacheManager
rg -n "CacheManager" --type py -B 2 -A 2 | head -100

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 7595


🏁 Script executed:

# Check if cache_manager is imported from elsewhere or defined in base_odds_manager
head -50 plugins/baseball-scoreboard/base_odds_manager.py | grep -i import

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 363


🏁 Script executed:

# Look at how odds_manager.py or manager.py instantiates base_odds_manager
rg -n "BaseOddsManager|cache_manager" plugins/baseball-scoreboard/manager.py -B 2 -A 2

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 3821


🏁 Script executed:

# Check the actual method signatures on cache_manager that are called
rg -n "cache_manager\.(get|set|delete|clear)" plugins/baseball-scoreboard/ --type py

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 2298


🏁 Script executed:

# Search for the actual CacheManager implementation from src
rg -n "class CacheManager" --type py -A 30 | head -80

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 55


🏁 Script executed:

# Check what methods dynamic_team_resolver uses on cache_manager (since it uses ttl)
sed -n '95,115p' plugins/baseball-scoreboard/dynamic_team_resolver.py | cat -n

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 1225


🏁 Script executed:

# Look for delete and clear method signatures in cache manager
rg -n "def (delete|clear)\(" --type py -B 2 -A 3 | grep -A 5 -B 5 "cache"

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 3331


🏁 Script executed:

# Look at the test file to see what methods MockCacheManager supports
sed -n '48,53p' plugins/basketball-scoreboard/test_score_fix_verification.py | cat -n

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 275


🏁 Script executed:

# Check if there are any other implementations or mocks of cache manager
rg -n "def get\(|def set\(|def delete\(|def clear" --type py -A 1 | grep -E "(def (get|set|delete|clear)|cache)" | head -40

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 3530


🏁 Script executed:

# Look at lines around the set() calls to understand the full context better
sed -n '160,175p' plugins/baseball-scoreboard/base_odds_manager.py | cat -n

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 909


🏁 Script executed:

# Check if the interval variable is used anywhere else in the file
rg -n "interval" plugins/baseball-scoreboard/base_odds_manager.py

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 743


🏁 Script executed:

# Check to verify delete() vs clear() method usage - see full context
sed -n '283,295p' plugins/baseball-scoreboard/base_odds_manager.py | cat -n

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 595


🏁 Script executed:

# Let me check if cache_manager is a parameter or if it's coming from somewhere specific
# Look at how BaseOddsManager is instantiated
rg -n "BaseOddsManager\(" plugins/baseball-scoreboard/ --type py -B 2 -A 2

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 597


🏁 Script executed:

# Check if there's documentation on what methods cache_manager should support
rg -n "cache_manager" plugins/baseball-scoreboard/manager.py -B 3 -A 3 | grep -E "(delete|clear)"

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 55


🏁 Script executed:

# Look at actual usages of delete() and clear() on cache_manager across the project
rg -n "cache_manager\.(delete|clear)\(" --type py -B 1 -A 1

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 2491


🏁 Script executed:

# Check if there's any actual CacheManager class definition in the repo
find . -type f -name "*.py" -exec grep -l "class CacheManager" {} \;

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 55


🏁 Script executed:

# See the actual implementation of set() to confirm it accepts ttl parameter
rg -n "def set\(" plugins/ --type py -A 3 | grep -A 3 "ttl"

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 771


🏁 Script executed:

# Verify the current usage - let's see the exact calls to set()
sed -n '163,170p' plugins/baseball-scoreboard/base_odds_manager.py

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 490


🏁 Script executed:

# Check all usages of cache_manager methods across the codebase
rg -n "cache_manager\.(get|set|delete|clear)\(" plugins/ --type py | wc -l

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 76


🏁 Script executed:

# Count delete vs clear_cache usage
echo "=== delete() usage ===" && rg -n "cache_manager\.delete\(" plugins/ --type py | wc -l
echo "=== clear_cache() usage ===" && rg -n "cache_manager\.clear_cache\(" plugins/ --type py | wc -l
echo "=== clear() usage ===" && rg -n "cache_manager\.clear\(" plugins/ --type py | wc -l

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 152


🏁 Script executed:

# Check the actual MockCacheManager to see what methods it should support
rg -n "class.*CacheManager|DummyCache" plugins/ --type py -A 10 | head -60

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 3079


🏁 Script executed:

# Final verification: check what methods are actually called on cache_manager in clear_cache method
sed -n '276,295p' plugins/baseball-scoreboard/base_odds_manager.py | cat -n

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 891


🏁 Script executed:

# Verify: does cache_manager have a clear_cache method or only delete/clear?
rg -n "cache_manager\.clear_cache" plugins/baseball-scoreboard/ --type py

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 521


🏁 Script executed:

# Check what clear_cache method actually calls
rg -n "def clear_cache" plugins/baseball-scoreboard/base_odds_manager.py -A 20

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 837


🏁 Script executed:

# Check the context of clear_cache calls in mlb_managers.py
sed -n '80,90p' plugins/baseball-scoreboard/mlb_managers.py | cat -n

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 657


🏁 Script executed:

# Compare: do these managers inherit from BaseOddsManager or use a different approach?
head -40 plugins/baseball-scoreboard/mlb_managers.py | grep -E "class|import|extends"

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 384


🏁 Script executed:

# Verify: what is cache_manager.clear_cache actually supposed to do?
rg -n "def clear_cache" plugins/ --type py -B 2 -A 8 | grep -A 10 "cache_manager"

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 753


🏁 Script executed:

# Final check: Look at what Baseball class defines (parent of BaseMLBManager)
rg -n "class Baseball\|def clear_cache" plugins/baseball-scoreboard/baseball.py -A 3 | head -40

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 55


🏁 Script executed:

# Confirm by checking what Baseball.clear_cache does
sed -n '1,400p' plugins/baseball-scoreboard/baseball.py | grep -A 5 "def clear_cache"

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 55


🏁 Script executed:

# Check the sports.py file for clear_cache method
rg -n "def clear_cache" plugins/baseball-scoreboard/sports.py -A 10

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 55


🏁 Script executed:

# Look at the full Baseball class definition
sed -n '1,100p' plugins/baseball-scoreboard/sports.py | cat -n

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 4895


Pass ttl parameter to set() calls; the interval variable is unused but should be applied as cache TTL.

The interval computed on line 114 is never used, and set() calls (lines 164, 169) don't pass a TTL value, so configured cache expiration is ignored. Use interval (or self.cache_ttl) as the TTL when caching.

The delete() method is correct—do not change it to clear_cache() as that method does not exist on the cache manager.

🔧 Suggested fix
         if odds_data:
-            self.cache_manager.set(cache_key, 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})
+            self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval)

Also applies to: 276-292

🧰 Tools
🪛 Ruff (0.15.0)

[error] 114-114: Local variable interval is assigned to but never used

Remove assignment to unused variable interval

(F841)


[warning] 121-121: Comment contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF003)

🤖 Prompt for AI Agents
In `@plugins/baseball-scoreboard/base_odds_manager.py` around lines 113 - 170, The
code computes interval = update_interval_seconds or self.update_interval but
never uses it; pass this interval as the TTL when calling self.cache_manager.set
so cached entries expire correctly. Update both places where
self.cache_manager.set(cache_key, ...) is called (the block saving odds_data and
the block saving {"no_odds": True}) to include the TTL variable (interval) so
the cache respects the configured expiration; keep the cache_key and use
existing cache_manager.set signature (add the TTL argument) and do not change
delete() usage.

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")
Loading