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
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-08-18",
"last_updated": "2026-08-19",
"plugins": [
{
"id": "cricket-scoreboard",
Expand Down Expand Up @@ -76,7 +76,7 @@
"last_updated": "2026-08-13",
"verified": true,
"screenshot": "",
"latest_version": "1.26.1"
"latest_version": "1.27.0"
},
{
"id": "basketball-scoreboard",
Expand All @@ -101,7 +101,7 @@
"last_updated": "2026-08-13",
"verified": true,
"screenshot": "",
"latest_version": "1.14.1"
"latest_version": "1.15.0"
},
{
"id": "calendar",
Expand Down Expand Up @@ -240,7 +240,7 @@
"last_updated": "2026-08-13",
"verified": true,
"screenshot": "",
"latest_version": "2.16.1"
"latest_version": "2.17.0"
},
{
"id": "geochron",
Expand Down Expand Up @@ -335,7 +335,7 @@
"last_updated": "2026-08-13",
"verified": true,
"screenshot": "",
"latest_version": "1.11.1",
"latest_version": "1.12.0",
"icon": "fas fa-hockey-puck"
},
{
Expand All @@ -359,7 +359,7 @@
"last_updated": "2026-08-13",
"verified": true,
"screenshot": "",
"latest_version": "1.11.1",
"latest_version": "1.12.0",
"icon": "fas fa-baseball-ball"
},
{
Expand Down Expand Up @@ -760,7 +760,7 @@
"last_updated": "2026-08-13",
"verified": true,
"screenshot": "",
"latest_version": "2.11.1"
"latest_version": "2.12.0"
},
{
"id": "static-image",
Expand Down Expand Up @@ -905,7 +905,7 @@
"last_updated": "2026-08-05",
"verified": true,
"screenshot": "",
"latest_version": "1.5.1",
"latest_version": "1.6.0",
"icon": "fas fa-fist-raised"
},
{
Expand Down Expand Up @@ -1048,7 +1048,7 @@
"downloads": 0,
"verified": true,
"screenshot": "",
"latest_version": "1.8.1",
"latest_version": "1.9.0",
"last_updated": "2026-08-13"
},
{
Expand Down Expand Up @@ -1095,7 +1095,7 @@
"last_updated": "2026-08-13",
"verified": true,
"screenshot": "",
"latest_version": "1.8.1"
"latest_version": "1.9.0"
},
{
"id": "jellyfin-now-playing",
Expand Down
18 changes: 18 additions & 0 deletions plugins/afl-scoreboard/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,24 @@
"title": "Schedule lookahead (days)",
"description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.",
"x-advanced": true
},
"no_data_interval_seconds": {
"type": "integer",
"default": 300,
"minimum": 5,
"maximum": 86400,
"title": "Idle live-check interval (seconds)",
"description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.",
"x-advanced": true
},
"live_idle_max_interval_seconds": {
"type": "integer",
"default": 900,
"minimum": 5,
"maximum": 86400,
"title": "Maximum idle live-check interval (seconds)",
"description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.",
"x-advanced": true
}
},
"additionalProperties": false,
Expand Down
25 changes: 18 additions & 7 deletions plugins/afl-scoreboard/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@
from afl_timezone import resolve_timezone_name
from afl_favorite_check import FavoriteTeamCheck


_ROOT_CONFIG_KEYS = (
"schedule_lookback_days",
"schedule_lookahead_days",
"no_data_interval_seconds",
"live_idle_max_interval_seconds",
)


# Which ESPN endpoint backs the league, for the favorite-team diagnostic.
FAVORITE_CHECK_KEY = 'afl'
FAVORITE_CHECK_LEAGUES = {FAVORITE_CHECK_KEY: ('AFL', 'australian-football/afl')}
Expand Down Expand Up @@ -308,13 +317,15 @@ def _adapt_config_for_manager(self) -> Dict[str, Any]:
"customization": customization_config,
})

# The schedule-window settings live at the plugin config root, and
# SportsCore reads them from the root of the config it is handed. This
# adapter builds its output key by key, so anything not named here is
# dropped -- which silently pinned every user to the defaults.
for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"):
if _window_key in self.config:
manager_config[_window_key] = self.config[_window_key]
# Plugin-root settings that SportsCore reads from the root of the config
# it is handed. This adapter builds its output key by key, so anything
# not named here is dropped -- which is how the schedule window silently
# pinned every user to the defaults, and would have done the same to the
# idle-poll settings. Generalised to a list so the next one added to
# SportsCore only has to be named once.
for _root_key in _ROOT_CONFIG_KEYS:
if _root_key in self.config:
manager_config[_root_key] = self.config[_root_key]
return manager_config

def _parse_display_mode_settings(self) -> Dict[str, str]:
Expand Down
9 changes: 8 additions & 1 deletion plugins/afl-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "afl-scoreboard",
"name": "AFL Scoreboard",
"version": "1.8.1",
"version": "1.9.0",
"author": "ChuckBuilds",
"description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.",
"category": "sports",
Expand All @@ -18,6 +18,13 @@
"afl_upcoming"
],
"versions": [
{
"version": "1.9.0",
"released": "2026-08-19",
"ledmatrix_min_version": "2.0.0",
"notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.",
"changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one."
},
{
"version": "1.8.1",
"released": "2026-08-18",
Expand Down
96 changes: 86 additions & 10 deletions plugins/afl-scoreboard/sports.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,28 @@ def _clamp_window(value: Any, fallback: int) -> int:
return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days))


# Backing off the live poll while a league has nothing on. Gentle at first --
# a gap between games in a live season should cost little -- then firmer, so a
# league months out of season stops polling on a live cadence altogether.
_IDLE_SHORT_STREAK = 6
_IDLE_SHORT_FACTOR = 2
_IDLE_LONG_STREAK = 24
_IDLE_LONG_FACTOR = 6
_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900


def _clamp_seconds(value: Any, fallback: int, low: int = 5,
high: int = 86400) -> int:
"""An interval in seconds, or the fallback when the value is unusable."""
try:
seconds = int(value)
except (TypeError, ValueError, OverflowError):
# OverflowError: json parses bare Infinity by default and int(inf)
# raises -- the same gap _clamp_window above already covers.
return fallback
return max(low, min(high, seconds))


class SportsCore(ABC):
def __init__(
self,
Expand Down Expand Up @@ -2493,7 +2515,20 @@ def __init__(
):
super().__init__(config, display_manager, cache_manager, logger, sport_key)
self.update_interval = self.mode_config.get("live_update_interval", 15)
self.no_data_interval = 300
# Read from the config root, where the schema declares them and the web
# UI writes them -- not from mode_config, which is the per-league block
# ({sport}_scoreboard) and never carries these keys. Looking them up
# there meant the saved value was invisible and every user silently kept
# the default. mode_config is still consulted as a fallback so a
# hand-placed per-league value keeps working.
self.no_data_interval = _clamp_seconds(
self.config.get("no_data_interval_seconds",
self.mode_config.get("no_data_interval_seconds")), 300)
self.live_idle_max_interval = _clamp_seconds(
self.config.get("live_idle_max_interval_seconds",
self.mode_config.get("live_idle_max_interval_seconds")),
_DEFAULT_LIVE_IDLE_MAX_SECONDS)
self._empty_live_streak = 0
# Log the configured interval for debugging
self.logger.info(
f"SportsLive initialized: live_update_interval={self.update_interval}s, "
Expand Down Expand Up @@ -3032,6 +3067,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None:
if game_id in self.game_update_timestamps:
del self.game_update_timestamps[game_id]

def _idle_live_interval(self) -> int:
"""How long to wait before looking for live games again, when there are none.

Escalates the longer nothing turns up, and any live game resets it, so
an in-season gap between games costs at most one escalated wait while
an out-of-season league stops polling on a live cadence entirely.

Capped rather than unbounded: the cost of backing off is how late the
first game after a quiet spell is noticed, and past the cap the saving
stops being worth that.
"""
streak = getattr(self, "_empty_live_streak", 0)
base = self.no_data_interval
ceiling = getattr(self, "live_idle_max_interval",
_DEFAULT_LIVE_IDLE_MAX_SECONDS)
# The ceiling bounds the un-escalated interval too. The two settings are
# independent integers with no cross-validation, so base > ceiling is a
# reachable config -- and returning base unclamped there made the wait
# *shrink* as the streak grew (3600s at streak 0, 900s at streak 24),
# the opposite of what the setting named "maximum" promises.
if streak >= _IDLE_LONG_STREAK:
return min(int(base * _IDLE_LONG_FACTOR), ceiling)
if streak >= _IDLE_SHORT_STREAK:
return min(int(base * _IDLE_SHORT_FACTOR), ceiling)
return min(base, ceiling)

def _note_live_fetch(self, found_live: bool) -> None:
"""Record whether a look for live games found any."""
if found_live:
if getattr(self, "_empty_live_streak", 0):
self.logger.info(
"Live games found after %d empty check(s); back to the "
"live update interval", self._empty_live_streak)
self._empty_live_streak = 0
else:
self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1

def update(self):
"""Update live game data and handle game switching."""
if not self.is_enabled:
Expand All @@ -3056,21 +3128,25 @@ def update(self):
# Only use no_data_interval if we've recently checked and confirmed there are no live games.
# This ensures we check for live games frequently even if the list is temporarily empty.
# Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes)
time_since_last_update = current_time - self.last_update
has_recently_checked = self.last_update > 0 and time_since_last_update < 300

# Whether the last look found anything, tracked explicitly rather than
# inferred from how long ago it was. The old form asked "did we check
# within the last 300s?" and only then used no_data_interval -- but
# once 300s had elapsed the answer became no, the interval dropped
# back to live_update_interval, and it fetched. no_data_interval could
# therefore never delay anything past 300s whatever it was set to.
# Measured on a live rig: an out-of-season NHL polled every ~5.5
# minutes around the clock, returning nothing every time.
if _live_games_attr:
# We have live games, use the configured update interval
interval = _update_interval_attr
elif has_recently_checked:
# We've checked recently and found no live games, use longer interval
interval = _no_data_interval_attr
else:
# First check or haven't checked in a while, use update interval to check for live games
interval = _update_interval_attr
interval = self._idle_live_interval()

# Original line from traceback (line 455), now with variables defined:
if current_time - self.last_update >= interval:
# What the previous look found, recorded before this one
# replaces it. The streak is what drives the back-off, and
# any live game resets it.
self._note_live_fetch(bool(_live_games_attr))
self.last_update = current_time

# Fetch rankings if enabled
Expand Down
Loading
Loading