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
2 changes: 1 addition & 1 deletion plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,7 @@
"last_updated": "2026-08-13",
"verified": true,
"screenshot": "",
"latest_version": "2.10.4"
"latest_version": "2.10.5"
},
{
"id": "static-image",
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.10.4",
"version": "2.10.5",
"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 All @@ -26,6 +26,12 @@
"soccer_upcoming"
],
"versions": [
{
"version": "2.10.5",
"released": "2026-08-14",
"ledmatrix_min_version": "2.0.0",
"notes": "Show fixtures up to two weeks out instead of one. The partial fetch that serves the display until the background fetch lands looked ahead seven days, while the full fetch it stands in for looks ahead fourteen, so a fixture inside the real window was missing from the board. Invisible in a league that plays daily and severe in one that plays weekly, where a whole matchweek can fall in the gap: on 14 August the Premier League's opening matchweek was 21-24 August, so the board showed exactly one game (Arsenal v Coventry on the 21st) and hid the other nine, including Manchester United on the 22nd. Reported by a user whose favourite team never appeared while other clubs did. Both horizons now come from one pair of constants so they cannot drift apart again."
},
{
"version": "2.10.4",
"released": "2026-08-14",
Expand Down
20 changes: 18 additions & 2 deletions plugins/soccer-scoreboard/sports.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ def _resolve_font_path(path: str) -> str:



# How far either side of now the schedule is fetched. The partial fetch that
# serves the display until the background fetch lands must not be narrower
# than the fetch it substitutes for, or a game inside the real window is
# missing from the panel until that completes.
_SCHEDULE_WINDOW_BACK = timedelta(days=14)
_SCHEDULE_WINDOW_FORWARD = timedelta(days=14)


class SportsCore(ABC):
def __init__(
self,
Expand Down Expand Up @@ -1316,8 +1324,16 @@ def _get_weeks_data(self) -> Optional[Dict]:
now = datetime.now(pytz.utc)
immediate_events = []

start_date = now + timedelta(weeks=-2)
end_date = now + timedelta(weeks=1)
# Same horizon as the full fetch this stands in for
# (_fetch_soccer_api_data, -14d..+14d). It used to end a week
# earlier, which is invisible in a league that plays daily and
# severe in one that plays weekly: on 2026-08-14 the Premier
# League's opening matchweek was 21-24 August, so a +7d horizon
# returned exactly one fixture (COV @ ARS on the 21st) and hid the
# other nine, including Man Utd on the 22nd. Reported as a
# favourite team never appearing while other clubs did.
start_date = now - _SCHEDULE_WINDOW_BACK
end_date = now + _SCHEDULE_WINDOW_FORWARD
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
response = self.session.get(
Expand Down
112 changes: 112 additions & 0 deletions plugins/soccer-scoreboard/test_schedule_horizon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""
Tests that the partial schedule fetch covers the same span as the full one.

Reported by a user: Manchester United never appeared even though their next
fixture was 22 August, and with favourites turned off the board showed exactly
one Premier League game -- Arsenal v Coventry.

Reproduced against ESPN on 2026-08-14:

-2w..+1w 20260731-20260821 -> 1 event (COV @ ARS on the 21st)
-2w..+4w 20260731-20260911 -> 30 events (MAN @ HUL on the 22nd, ...)

_get_weeks_data() is the partial that serves the display until the background
fetch lands, and it ended a week earlier than _fetch_soccer_api_data(), the
fetch it substitutes for. Invisible in a league that plays daily; severe in one
that plays weekly, where a whole matchweek can sit in the gap. The Premier
League's opening matchweek was 21-24 August, so a +7d horizon caught the Friday
opener and hid the other nine fixtures.

Run: <core-venv>/bin/python plugins/soccer-scoreboard/test_schedule_horizon.py
"""

import ast
import sys
from datetime import datetime, timedelta
from pathlib import Path

plugin_dir = Path(__file__).parent
sys.path.insert(0, str(plugin_dir))
for candidate in (Path("/home/rackpi/projects/LEDMatrix"),
plugin_dir.parents[2] / "LEDMatrix"):
if (candidate / "src" / "plugin_system" / "base_plugin.py").exists():
sys.path.insert(0, str(candidate))
break

import sports # noqa: E402

failures = []


def check(label, ok):
print((" PASS " if ok else " FAIL ") + label)
if not ok:
failures.append(label)


def main():
print("the partial fetch is not narrower than the fetch it stands in for")
# The full fetch's span, read from soccer_managers so the two cannot drift
# apart silently: that file is where the background fetch is built.
managers_src = (plugin_dir / "soccer_managers.py").read_text(encoding="utf-8")
tree = ast.parse(managers_src)
full = next((n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)
and n.name == "_fetch_soccer_api_data"), None)
check("_fetch_soccer_api_data exists", full is not None)

deltas = []
for node in ast.walk(full):
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "timedelta":
for kw in node.keywords:
if isinstance(kw.value, ast.Constant):
deltas.append((kw.arg, kw.value.value))
full_days = max((v for unit, v in deltas if unit == "days"), default=None)
check("its horizon is discoverable (%s)" % deltas, full_days is not None)

forward = sports._SCHEDULE_WINDOW_FORWARD
back = sports._SCHEDULE_WINDOW_BACK
check("the partial's forward horizon is a timedelta",
isinstance(forward, timedelta))
if full_days is not None:
check("partial forward (%dd) >= full forward (%dd)"
% (forward.days, full_days), forward.days >= full_days)
check("partial back (%dd) >= full back (%dd)"
% (back.days, full_days), back.days >= full_days)

print("\nthe reported fixture falls inside the horizon")
# The user's case, as dates rather than a live API call so the test stays
# deterministic: on 2026-08-14, Man Utd played on the 22nd.
today = datetime(2026, 8, 14)
fixture = datetime(2026, 8, 22)
check("a fixture 8 days out is covered", today + forward >= fixture)
check("...and was not, at the old +7d horizon",
today + timedelta(days=7) < fixture)

print("\nthe whole opening matchweek is covered, not just its first day")
# 21-24 August. Catching only the Friday game is what produced
# "the only game it's showing is Arsenal vs. Coventry".
matchweek_end = datetime(2026, 8, 24)
check("the matchweek's last day is inside the horizon",
today + forward >= matchweek_end)

print("\n_get_weeks_data uses the constants rather than its own numbers")
src = (plugin_dir / "sports.py").read_text(encoding="utf-8")
fn = next((n for n in ast.walk(ast.parse(src)) if isinstance(n, ast.FunctionDef)
and n.name == "_get_weeks_data"), None)
check("_get_weeks_data exists", fn is not None)
literal_deltas = [n for n in ast.walk(fn)
if isinstance(n, ast.Call)
and getattr(n.func, "id", "") == "timedelta"]
check("no hard-coded window remains in it", not literal_deltas)
names = {n.id for n in ast.walk(fn) if isinstance(n, ast.Name)}
check("it references both constants",
{"_SCHEDULE_WINDOW_BACK", "_SCHEDULE_WINDOW_FORWARD"} <= names)

print("\n%s" % ("FAILED: %d" % len(failures) if failures
else "All checks passed"))
return 1 if failures else 0


if __name__ == "__main__":
sys.exit(main())
Loading