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
9 changes: 9 additions & 0 deletions docs/plugin-development/08-shared-sports-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,15 @@ the core actually ships:
`_swrr_advance` and `_refresh_switch_mode_managers`, or the multi-league
plugins' `_resolve_managers_for_mode` and `_extract_mode_type`). They go
when the code around them is reconciled.
- **Converged at 3.8.1 (sports family 5, the game-over check):**
`src.common.sports_game_over`. `SportsLive` (all nine) inherits
`SportsGameOverMixin`, listed before `SportsLiveSharedMixin`, and keeps
declaring `FINAL_PERIOD`, the period from which a 0:00 clock ends a game
(hockey 3; basketball, football and lacrosse 4; the rest `None`). Baseball's
`BaseballLive` keeps its postponed/suspended override, which calls
`super()`. `scripts/test_game_over_mixin_copies.py` fails if a copy, a
guarded import, a missing or misplaced base or a changed `FINAL_PERIOD`
comes back; `scripts/test_game_over_check.py` pins the answers.
- **Not converging (documented forks):** `dynamic_team_resolver` (plugin copies
take `cache_manager` in the constructor; the core's does not — different
API), ufc's `base_odds_manager` (MMA athlete-odds fork), and — until the core
Expand Down
45 changes: 18 additions & 27 deletions plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,8 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "1.57.4",
"ledmatrix_min_version": "3.8.0",
"commit": "a5194139de39c4999562ee0d90d9e079943d1861"
"latest_version": "1.58.0",
"ledmatrix_min_version": "3.8.1"
},
{
"id": "basketball-scoreboard",
Expand All @@ -107,9 +106,8 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "1.42.2",
"ledmatrix_min_version": "3.8.0",
"commit": "8db9d3ebe374dc4155906ad29f6f5baa5536fe1c"
"latest_version": "1.43.0",
"ledmatrix_min_version": "3.8.1"
},
{
"id": "calendar",
Expand Down Expand Up @@ -283,9 +281,8 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "3.18.9",
"ledmatrix_min_version": "3.8.0",
"commit": "7b232936a6503ac585006946327fb584dc875d62"
"latest_version": "3.19.0",
"ledmatrix_min_version": "3.8.1"
},
{
"id": "geochron",
Expand Down Expand Up @@ -385,10 +382,9 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "1.42.5",
"latest_version": "1.43.0",
"icon": "fas fa-hockey-puck",
"ledmatrix_min_version": "3.8.0",
"commit": "a5194139de39c4999562ee0d90d9e079943d1861"
"ledmatrix_min_version": "3.8.1"
},
{
"id": "lacrosse-scoreboard",
Expand All @@ -411,10 +407,9 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "1.36.4",
"latest_version": "1.37.0",
"icon": "fas fa-baseball-ball",
"ledmatrix_min_version": "3.8.0",
"commit": "8db9d3ebe374dc4155906ad29f6f5baa5536fe1c"
"ledmatrix_min_version": "3.8.1"
},
{
"id": "birdnet-go",
Expand Down Expand Up @@ -847,9 +842,8 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "2.39.5",
"ledmatrix_min_version": "3.8.0",
"commit": "8db9d3ebe374dc4155906ad29f6f5baa5536fe1c"
"latest_version": "2.40.0",
"ledmatrix_min_version": "3.8.1"
},
{
"id": "static-image",
Expand Down Expand Up @@ -1007,10 +1001,9 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "1.19.5",
"latest_version": "1.20.0",
"icon": "fas fa-fist-raised",
"ledmatrix_min_version": "3.8.0",
"commit": "8db9d3ebe374dc4155906ad29f6f5baa5536fe1c"
"ledmatrix_min_version": "3.8.1"
},
{
"id": "weather",
Expand Down Expand Up @@ -1164,10 +1157,9 @@
"downloads": 0,
"verified": true,
"screenshot": "",
"latest_version": "1.35.3",
"latest_version": "1.36.0",
"last_updated": "2026-10-05",
"ledmatrix_min_version": "3.8.0",
"commit": "8db9d3ebe374dc4155906ad29f6f5baa5536fe1c"
"ledmatrix_min_version": "3.8.1"
},
{
"id": "tidbyt-baseball-scoreboard",
Expand Down Expand Up @@ -1213,9 +1205,8 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "1.34.3",
"ledmatrix_min_version": "3.8.0",
"commit": "8db9d3ebe374dc4155906ad29f6f5baa5536fe1c"
"latest_version": "1.35.0",
"ledmatrix_min_version": "3.8.1"
},
{
"id": "jellyfin-now-playing",
Expand Down
17 changes: 17 additions & 0 deletions plugins/afl-scoreboard/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## [1.36.0] - 2026-10-05

### Changed
- Requires LEDMatrix core 3.8.1 (`ledmatrix_min_version` and
`compatible_versions`), the first release that ships
`src/common/sports_game_over.py`. The store refuses to install or update
this version onto an older core.
- `SportsLive` inherits `SportsGameOverMixin` (`src.common.sports_game_over`)
and keeps declaring `FINAL_PERIOD` (None).

### Removed
- `SportsLive._is_game_really_over`, this plugin's copy of core's game-over
check.

No change in behaviour: every game-over answer and every safety-harness
render is identical.

## [1.35.3] - 2026-10-05

### Changed
Expand Down
10 changes: 8 additions & 2 deletions 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.35.3",
"version": "1.36.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,12 @@
"afl_upcoming"
],
"versions": [
{
"version": "1.36.0",
"released": "2026-10-05",
"ledmatrix_min_version": "3.8.1",
"notes": "Requires LEDMatrix core 3.8.1, the first release that ships src.common.sports_game_over. SportsLive uses core's game-over check (SportsGameOverMixin) and its own copy of _is_game_really_over is removed; FINAL_PERIOD stays declared here (None). No change in behaviour: every game-over answer and every safety-harness render is identical."
},
{
"version": "1.35.3",
"released": "2026-10-05",
Expand Down Expand Up @@ -552,6 +558,6 @@
"class_name": "AflScoreboardPlugin",
"config_schema": "config_schema.json",
"compatible_versions": [
">=3.8.0"
">=3.8.1"
]
}
57 changes: 3 additions & 54 deletions plugins/afl-scoreboard/sports.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from src.common.sports_fetch import SportsFetchMixin
from src.common.sports_display_rules import SportsCardOptionsMixin, SportsGameRulesMixin
from src.common.sports_font_path import resolve_font_path as _resolve_font_path
from src.common.sports_game_over import SportsGameOverMixin
from src.common.sports_celebration import SportsCelebrationMixin
# Helpers every scoreboard carried a private copy of; core ships them from
# 3.5.0 (the manifest's floor). The private aliases keep this module's names.
Expand Down Expand Up @@ -3111,9 +3112,9 @@ def display(self, force_clear=False) -> bool:
return True


class SportsLive(SportsCelebrationMixin, SportsLiveSharedMixin, SportsCore):
class SportsLive(SportsCelebrationMixin, SportsGameOverMixin, SportsLiveSharedMixin, SportsCore):
SKIN_MODE = "live"
#: Period from which a 0:00 clock ends a game (see _is_game_really_over).
#: Period from which a 0:00 clock ends a game (core's SportsGameOverMixin).
#: None: the clock counts up, so only the final status ends a game.
FINAL_PERIOD: Optional[int] = None

Expand Down Expand Up @@ -3556,58 +3557,6 @@ def format_score(score):
except Exception as e:
self.logger.error(f"Error displaying live game: {e}", exc_info=True)

def _is_game_really_over(self, game: Dict) -> bool:
"""Whether a game ESPN still lists as live has in fact ended.

It has when its period text says final. From period ``FINAL_PERIOD``
on, a clock at 0:00 ends it too, unless the score is level: a tie at
the end of regulation goes to overtime, and a game that does end tied
says final. With ``FINAL_PERIOD = None`` the clock never ends a game.
"""
game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}"

# ESPN can send the key as null, and .get()'s default only covers a
# missing key, so a None here crashed the whole live update.
raw_period_text = game.get("period_text")
period_text = raw_period_text.lower() if isinstance(raw_period_text, str) else ""
if "final" in period_text:
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning True - 'final' in period_text='{period_text}'"
)
return True

# Same for a null or non-numeric period: treat it as period 0.
try:
period = int(game.get("period") or 0)
except (TypeError, ValueError, OverflowError):
period = 0
# Only a clock string is read: "0:00" and ":00" are zero; ":40" is not.
clock = game.get("clock")
clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00")

if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero:
try:
tied = int(game["away_score"]) == int(game["home_score"])
except (KeyError, TypeError, ValueError, OverflowError):
tied = False # a missing or unreadable score leaves it to the clock
if not tied:
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning True - clock at 0:00 (clock='{clock}', period={period})"
)
return True
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning False - tied at 0:00 (period={period}), overtime next"
)
return False

self.logger.debug(
f"_is_game_really_over({game_str}): returning False"
)
return False

def update(self):
"""Update live game data and handle game switching."""
if not self.is_enabled:
Expand Down
19 changes: 19 additions & 0 deletions plugins/baseball-scoreboard/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## [1.58.0] - 2026-10-05

### Changed
- Requires LEDMatrix core 3.8.1 (`ledmatrix_min_version` and
`compatible_versions`), the first release that ships
`src/common/sports_game_over.py`. The store refuses to install or update
this version onto an older core.
- `SportsLive` inherits `SportsGameOverMixin` (`src.common.sports_game_over`)
and keeps declaring `FINAL_PERIOD` (None).
- `BaseballLive._is_game_really_over` (the postponed/suspended check)
stays; its `super()` call now reaches core's.

### Removed
- `SportsLive._is_game_really_over`, this plugin's copy of core's game-over
check.

No change in behaviour: every game-over answer and every safety-harness
render is identical.

## [1.57.4] - 2026-10-05

### Fixed
Expand Down
10 changes: 8 additions & 2 deletions plugins/baseball-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "baseball-scoreboard",
"name": "Baseball Scoreboard",
"version": "1.57.4",
"version": "1.58.0",
"update_interval": 60,
"author": "ChuckBuilds",
"description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules",
Expand Down Expand Up @@ -31,6 +31,12 @@
"branch": "main",
"plugin_path": "plugins/baseball-scoreboard",
"versions": [
{
"version": "1.58.0",
"released": "2026-10-05",
"ledmatrix_min_version": "3.8.1",
"notes": "Requires LEDMatrix core 3.8.1, the first release that ships src.common.sports_game_over. SportsLive uses core's game-over check (SportsGameOverMixin) and its own copy of _is_game_really_over is removed; FINAL_PERIOD stays declared here (None). BaseballLive keeps its postponed/suspended check, which now defers to core's. No change in behaviour: every game-over answer and every safety-harness render is identical."
},
{
"version": "1.57.4",
"released": "2026-10-05",
Expand Down Expand Up @@ -874,6 +880,6 @@
"class_name": "BaseballScoreboardPlugin",
"entry_point": "manager.py",
"compatible_versions": [
">=3.8.0"
">=3.8.1"
]
}
57 changes: 3 additions & 54 deletions plugins/baseball-scoreboard/sports.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def resolve_font_name(font_name: str) -> str:
from src.common.sports_fetch import SportsFetchMixin
from src.common.sports_display_rules import SportsCardOptionsMixin, SportsGameRulesMixin
from src.common.sports_font_path import resolve_font_path as _resolve_font_path
from src.common.sports_game_over import SportsGameOverMixin
# Helpers every scoreboard carried a private copy of; core ships them from
# 3.5.0 (the manifest's floor). The private aliases keep this module's names.
from src.common.sports_helpers import (
Expand Down Expand Up @@ -2986,9 +2987,9 @@ def display(self, force_clear=False) -> bool:
return True


class SportsLive(SportsLiveSharedMixin, SportsCore):
class SportsLive(SportsGameOverMixin, SportsLiveSharedMixin, SportsCore):
SKIN_MODE = "live"
#: Period from which a 0:00 clock ends a game (see _is_game_really_over).
#: Period from which a 0:00 clock ends a game (core's SportsGameOverMixin).
#: None: innings, not a clock.
FINAL_PERIOD: Optional[int] = None

Expand Down Expand Up @@ -3049,58 +3050,6 @@ def __init__(
self.game_update_timestamps = {} # {game_id: {"clock": timestamp, "score": timestamp, "last_seen": timestamp}}
self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default

def _is_game_really_over(self, game: Dict) -> bool:
"""Whether a game ESPN still lists as live has in fact ended.

It has when its period text says final. From period ``FINAL_PERIOD``
on, a clock at 0:00 ends it too, unless the score is level: a tie at
the end of regulation goes to overtime, and a game that does end tied
says final. With ``FINAL_PERIOD = None`` the clock never ends a game.
"""
game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}"

# ESPN can send the key as null, and .get()'s default only covers a
# missing key, so a None here crashed the whole live update.
raw_period_text = game.get("period_text")
period_text = raw_period_text.lower() if isinstance(raw_period_text, str) else ""
if "final" in period_text:
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning True - 'final' in period_text='{period_text}'"
)
return True

# Same for a null or non-numeric period: treat it as period 0.
try:
period = int(game.get("period") or 0)
except (TypeError, ValueError, OverflowError):
period = 0
# Only a clock string is read: "0:00" and ":00" are zero; ":40" is not.
clock = game.get("clock")
clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00")

if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero:
try:
tied = int(game["away_score"]) == int(game["home_score"])
except (KeyError, TypeError, ValueError, OverflowError):
tied = False # a missing or unreadable score leaves it to the clock
if not tied:
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning True - clock at 0:00 (clock='{clock}', period={period})"
)
return True
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning False - tied at 0:00 (period={period}), overtime next"
)
return False

self.logger.debug(
f"_is_game_really_over({game_str}): returning False"
)
return False

def _is_favorite_game(self, game: Dict) -> bool:
return (
game.get("home_abbr") in self.favorite_teams
Expand Down
Loading
Loading