Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 3 additions & 4 deletions plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -844,12 +844,11 @@
"plugin_path": "plugins/soccer-scoreboard",
"stars": 0,
"downloads": 0,
"last_updated": "2026-10-02",
"last_updated": "2026-10-03",
"verified": true,
"screenshot": "",
"latest_version": "2.39.2",
"ledmatrix_min_version": "3.8.0",
"commit": "886d2038d22a35c962beac0ea261543339cffec9"
"latest_version": "2.39.3",
"ledmatrix_min_version": "3.8.0"
},
{
"id": "static-image",
Expand Down
17 changes: 17 additions & 0 deletions plugins/soccer-scoreboard/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## [2.39.3] - 2026-10-03

### Fixed
- A start or reload no longer floods ESPN. With eight leagues, every start
logged ~90 `NameResolutionError`s for site.api.espn.com within a minute
(plus `update() timed out`), and six hours made 3,812 requests. On a cache
miss `_fetch_soccer_api_data` submitted the league's schedule window to the
core's background service *and* fetched the same window on the spot as
stand-in data (`_get_weeks_data`), in both the recent and the upcoming
manager -- and ESPN's rejection of date ranges makes each window 29 day
requests. A miss now fetches the window once on the calling thread
(`_fetch_season_directly`, which caches it), under a lock per cache key so
the league's other manager waits and reads the cache.
- Every manager shares the core's connection pool (`share_connection_pool`,
on cores that have the fetch service) instead of opening its own, so a
connection -- and its DNS lookup -- is reused across leagues.

## [2.39.2] - 2026-10-02

### Fixed
Expand Down
8 changes: 7 additions & 1 deletion plugins/soccer-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "soccer-scoreboard",
"name": "Soccer Scoreboard",
"version": "2.39.2",
"version": "2.39.3",
"author": "ChuckBuilds",
"description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup",
"category": "sports",
Expand Down Expand Up @@ -53,6 +53,12 @@
"soccer_usa.1_upcoming"
],
"versions": [
{
"version": "2.39.3",
"released": "2026-10-03",
"ledmatrix_min_version": "3.8.0",
"notes": "Startup and hourly ESPN traffic cut sharply. A league's schedule window is now fetched once per cache miss: it used to be submitted to the core's background service and fetched again on the spot as stand-in data, by both the recent and the upcoming manager, so each league asked for its window up to four times. A per-window lock makes the league's other manager wait and reuse the cached result, and every manager shares the core's connection pool (on cores that have it), so connections and DNS lookups are reused. Measured against live ESPN with eight leagues on core main: ~440 requests per start became ~260, and ~470 per hourly refresh became ~250; with LEDMatrix core's month-sized date fetches (LEDMatrix #751) a start is 39 requests and a refresh about 20. No display change."
},
{
"version": "2.39.2",
"released": "2026-10-02",
Expand Down
154 changes: 74 additions & 80 deletions plugins/soccer-scoreboard/soccer_managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@

import logging
import shutil
import threading
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, ClassVar, Dict, Optional
import pytz

from sports import SportsCore, SportsLive, SportsRecent, SportsUpcoming

try:
# Core's shared connection pool (core #702); older cores have none.
from src.common.fetch_service import share_connection_pool
except ImportError:
share_connection_pool = None

# ESPN API base URL for soccer
ESPN_SOCCER_BASE_URL = "https://site.api.espn.com/apis/site/v2/sports/soccer"

Expand Down Expand Up @@ -42,6 +49,18 @@ class BaseSoccerManager(SportsCore):
_shared_data = None
_last_shared_update = 0

# One schedule fetch per cache key at a time, across every manager. A
# league's recent and upcoming managers read the same key and come due in
# the same update(), so without this both missed the cache and both
# fetched the window.
_window_locks: ClassVar[Dict[str, threading.Lock]] = {}
_window_locks_guard: ClassVar[threading.Lock] = threading.Lock()

@classmethod
def _window_lock(cls, cache_key: str) -> threading.Lock:
with cls._window_locks_guard:
return cls._window_locks.setdefault(cache_key, threading.Lock())

def __init__(self, config: Dict[str, Any], display_manager, cache_manager, league_key: str):
"""
Initialize base soccer manager.
Expand All @@ -68,6 +87,19 @@ def __init__(self, config: Dict[str, Any], display_manager, cache_manager, leagu
self.sport = "soccer"
self.league = league_key

# Every manager -- three per league -- built its own Session and so
# its own connection pool: each new connection a DNS lookup, all at
# once at startup. On a core with the shared fetch service they now
# share one pool per retry policy (the same policy they mount), so a
# connection one league opened serves the next.
if share_connection_pool is not None:
try:
share_connection_pool(
self.session, self.session.get_adapter("https://").max_retries
)
except Exception as e: # noqa: BLE001 - its own pool still works
self.logger.debug(f"Could not share the connection pool: {e}")

# National-team flags (FIFA World Cup) live in a dedicated subdirectory so
# they never collide with club logos that share an abbreviation — e.g. ESP
# (Spain vs Espanyol), POR (Portugal vs Portland Timbers), COL (Colombia vs
Expand Down Expand Up @@ -131,101 +163,63 @@ def _setup_national_flags_dir(self, club_logo_dir: Path) -> Path:
return flags_dir

def _fetch_soccer_api_data(self, use_cache: bool = True) -> Optional[Dict]:
"""
Fetches game data for the soccer league using background threading.
Returns cached data immediately if available, otherwise starts background fetch.
"""The league's schedule over the configured window: cached, or
fetched once on this thread and cached.

This used to submit the window to the core's background service *and*
fetch the same window here at once, for "immediate" partial data --
the same request twice, every time the cache missed. With ESPN
rejecting date ranges, each was 29 day requests per league; eight
leagues at startup made ~450 requests and ~90 NameResolutionErrors
on a Pi. A fetch here costs what the stand-in did and nothing else.

The window lock makes the league's other manager wait for this
fetch and read its result from the cache instead of repeating it.
"""
now = datetime.now(pytz.utc)

# The window the user configured, not a fixed fortnight. This is the
# authoritative fetch; leaving it hard-coded meant a widened setting
# showed briefly from the stand-in fetch and then vanished when this
# one completed and replaced the data.
start_date = now - timedelta(days=self.schedule_lookback_days)
end_date = now + timedelta(days=self.schedule_lookahead_days)
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"

cache_key = f"soccer_{self.league_key}_schedule_{date_str}"
url = f"{ESPN_SOCCER_BASE_URL}/{self.league_key}/scoreboard"

# Check cache first
if use_cache:
cached_data = self.cache_manager.get(cache_key)
if cached_data:
# Validate cached data structure
if isinstance(cached_data, dict) and "events" in cached_data:
self.logger.info(f"Using cached schedule for {self.league_name}")
return cached_data
elif isinstance(cached_data, list):
# Handle old cache format (list of events)
self.logger.info(
f"Using cached schedule for {self.league_name} (legacy format)"
)
return {"events": cached_data}
else:
self.logger.warning(
f"Invalid cached data format for {self.league_name}: {type(cached_data)}"
)
# Clear invalid cache
self.cache_manager.delete(cache_key)

# Start background fetch if service is available
if (
self.background_service
and self.background_enabled
and self._background_fetches_espn_ranges()
):
self.logger.info(
f"Starting background fetch for {self.league_name} schedule..."
)

def fetch_callback(result):
"""Callback when background fetch completes."""
if result.success:
self.logger.info(
f"Background fetch completed for {self.league_name}: {len(result.data.get('events', []))} events"
)
else:
self.logger.error(
f"Background fetch failed for {self.league_name}: {result.error}"
)

# Get background service configuration
background_config = self.mode_config.get("background_service", {})
timeout = background_config.get("request_timeout", 30)
max_retries = background_config.get("max_retries", 3)
priority = background_config.get("priority", 2)

# Submit background fetch request
request_id = self.background_service.submit_fetch_request(
sport="soccer",
year=now.year,
url=url,
cache_key=cache_key,
params={"dates": date_str, "limit": 1000},
headers=self.headers,
timeout=timeout,
max_retries=max_retries,
priority=priority,
callback=fetch_callback,
)

# Track the request
if not hasattr(self, 'background_fetch_requests'):
self.background_fetch_requests = {}
self.background_fetch_requests[date_str] = request_id

# For immediate response, try to get partial data
partial_data = self._get_weeks_data()
if partial_data:
return partial_data
else:
# No background service, or a core that would send this range to
# ESPN as-is (rejected with 400 since 2026-09-15): fetch it here.
with self._window_lock(cache_key):
if use_cache:
cached = self._cached_window(cache_key)
if cached is not None:
return cached
return self._fetch_season_directly(
url, date_str, cache_key, f"{self.league_name}"
)

def _cached_window(self, cache_key: str) -> Optional[Dict]:
"""The schedule cached under ``cache_key``, or None."""
cached_data = self.cache_manager.get(cache_key)
if not cached_data:
return None
# Validate cached data structure
if isinstance(cached_data, dict) and "events" in cached_data:
self.logger.info(f"Using cached schedule for {self.league_name}")
return cached_data
if isinstance(cached_data, list):
# Handle old cache format (list of events)
self.logger.info(
f"Using cached schedule for {self.league_name} (legacy format)"
)
return {"events": cached_data}
self.logger.warning(
f"Invalid cached data format for {self.league_name}: {type(cached_data)}"
)
# Clear invalid cache
self.cache_manager.delete(cache_key)
return None

def _fetch_data(self) -> Optional[Dict]:
"""Fetch data using shared data mechanism or direct fetch for live."""
if isinstance(self, SoccerLiveManager):
Expand Down
Loading
Loading