mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-04 02:08:06 +00:00
* fix(fonts): resolve asset paths against the install root, not the cwd
FontManager built its catalog from cwd-relative paths ('assets/fonts'),
so any process started outside the install root — the plugin safety
harness on CI being the recurring case — found no fonts and silently
degraded every plugin to PIL's default face. Several plugins grew
per-plugin workarounds for exactly this (countdown, text-display,
tide-display in the plugins monorepo).
Catalog population now falls back to the install root derived from this
module's location when the cwd-relative path is missing; behavior when
running from the install root is unchanged. Verified: resolve_font
returns the real FreeType face from a foreign cwd, and the full unit
suites (266 tests) pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* docs: seed CHANGELOG.md with the module-availability release discipline
The plugins monorepo's sunset rule ('delete a bundled fallback copy only
when the manifest floors on the first core release shipping the module')
needs core module additions recorded against version numbers. Seeds the
changelog at 3.1.0 and documents the discipline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* ci: enroll the core unit suites in a dedicated job
The existing workflow ran only the three plugin-harness suites; the
skin-system, font-manager, data-source, extractor, scroll-helper,
adaptive-layout, and loader-compat suites (266 tests) existed but never
ran in CI, so a refactor of src/base_classes or src/common could regress
them silently. Also enrolls the new sports characterization and
element-style suites landing in this branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* feat: ship src/element_style — the per-element style resolver plugins already expect
Three plugins (of-the-day, ledmatrix-music, football-scoreboard) import
src.element_style behind guarded try/except with classic fallbacks, but
the module never existed in core, so the richer per-element styling UI
those code paths implement has been dormant. This lands it:
- ElementStyleResolver.style() resolves per-element font/size/color with
the key semantic the consumers encode: a config value counts as
user-forced only when it differs from the schema default (the web UI
bakes defaults into config.json on save), and untouched configs
resolve to exactly the caller's classic values — byte-identical
rendering, proven by of-the-day's committed goldens passing unchanged.
- defaults_from_schema_file parses both declaration forms (the compact
x-style-elements map and hand-written customization blocks).
- expand_style_elements() expands x-style-elements into full config
blocks; schema_manager.load_schema() applies it (guarded, no-op for
schemas without the declaration) so the config form and defaults
merging see the expanded UI.
- Fonts resolve cwd-independently with (path, size) caching; .bdf loads
via freetype like FontManager; nothing in the module raises out of
style().
Verified: 31 new unit tests; of-the-day's previously-skipped 9-test
spec suite now runs and passes; football's resolver tests pass (27);
music's 38 plugin tests pass; schema-manager suites pass (43).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* test: characterization suite for src/base_classes/sports.py ahead of unification
Pins current behavior before the planned merge of the nine drifted
plugin copies back into this ancestor: the _extract_game_details_common
key contract per sport (reusing GUARANTEED_KEYS from the skin tests),
update() flows for upcoming/recent/live against cache-seeded fixtures
under frozen time, rendering smoke per mode class, and guard rails on
the skin-system seam.
Five surprising behaviors are pinned AS-IS and flagged in comments so
the merge changes them knowingly or not at all: is_upcoming also
matching status.type.name; hockey dropping events whose competitors
lack 'statistics'; baseball reading the event-level status for innings;
no past-date filter in upcoming; and favorites-only mode with an empty
favorites list showing nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* ci: restrict the test workflow's GITHUB_TOKEN to contents:read
CodeQL flagged the new unit-tests job for running with the default
unrestricted token; the pre-existing job had the same exposure. Both
jobs only check out the repo and run pytest, so a workflow-level
contents:read is sufficient.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* refactor(sports): convert sports.py into a package (pure move)
Phase B1a of docs/SPORTS_UNIFICATION.md. src/base_classes/sports.py
becomes a package so the upcoming capability modules have a home and
diffs show their blast radius:
sports/__init__.py re-exports the public API
sports/core.py SportsCore
sports/modes.py SportsUpcoming / SportsRecent / SportsLive
No logic change: the 1515 class-body lines are byte-identical to the
original (verified by concatenating the two modules and diffing against
HEAD). Only module docstrings and the redistributed import blocks are
new. MRO and __abstractmethods__ are unchanged, and every existing
import site — including 'from src.base_classes.sports import SportsCore'
in the sport subclasses, the skin tests, and the characterization
suite — resolves through the package __init__.
One test edit was required: the characterization suite monkeypatched
'src.base_classes.sports.get_background_service', which is no longer a
module attribute on a package. Retargeted to
'src.base_classes.sports.core.get_background_service' — the module whose
globals SportsCore.__init__ actually resolves, so the patch is effective
exactly as before. No test logic or assertion changed.
Also adds docs/SPORTS_UNIFICATION.md: the architecture for the whole
B1-B5 sequence — how upgradability (guarded imports, capability probing,
frozen view-model keys, the sunset rule), reusability (promote only what
all nine copies share), and modularity (capabilities as opt-in mixins
rather than config branches, variants as named strategies, sport-unique
code as declared override points) are kept as three separate mechanisms.
Verified: characterization + skin 94 passed; the 10-file unit suite 338
passed; test/plugins 60 passed — all identical to pre-change counts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* feat(sports): promote the nine universal methods into the base classes
Phase B1b of docs/SPORTS_UNIFICATION.md. Every method here is present in
all nine bundled plugin sports.py copies and absent from core, so this is
reuse of code the fleet already agreed on — not new behavior. The
promotions are inert until B5: the plugins' own overrides still run.
SportsCore: cleanup, _get_layout_offset, _load_custom_font_from_element_config
SportsUpcoming: _select_games_for_display
SportsRecent: _get_zero_clock_duration, _clear_zero_clock_tracking,
_select_recent_games_for_display
SportsLive: _is_game_really_over, _detect_stale_games
Where the copies disagreed, the canonical form was chosen on evidence and
the genuine per-sport differences became seams rather than branches:
- _favorite_key(game, side) -- NRL matches favorites on team id because its
abbreviations are ambiguous (NEW is both Newcastle Knights and New
Zealand Warriors). Default is the abbreviation; NRL overrides. Core never
learns the string nrl.
- FINAL_PERIOD / CLOCK_COUNTS_DOWN -- hockey ends in P3, and soccer/afl/nrl
clocks count UP, so 0:00 means kickoff, not expiry.
- _config_schema_path() / _font_root() -- plugin-supplied locations, never
derived from this module's __file__.
BEHAVIOR CHANGE (baseball, ufc): the rejected variant coerced a missing or
non-str clock to the literal 0:00 and then declared the game over at
period >= 4. MLB has no game clock and period is the inning, so live games
were being evicted from the 5th inning onward; UFC likewise. The promoted
variant skips the clock check when the clock is unusable -- it fails safe
(keeps showing the game) instead of failing destructive.
Also fixes a regression from the package move in e591cec: the bodies were
byte-identical but __file__ gained a directory, so _resolve_project_path's
parents[2] silently began resolving to <root>/src instead of the repo root.
Both it and _font_root now derive from a single _INSTALL_ROOT constant, so
a future move needs one line changed rather than two hand-counted depths.
Tests assert the resolved values, not the index.
The font loader takes baseball's body (BDF memo cache + native-strike
retry) under hockey's Optional signature -- the older lineage is the
correct one here, and basketball's positional str default breaks on an
explicit None. It resolves through _font_root rather than the cwd, so it
does not reintroduce the bug just fixed for FontManager, and delegates to
FontManager for the alias table and BDF header parse instead of shipping
second copies. cleanup gained the two new font caches and still leaves
background_service alone -- it is a process-wide singleton.
Verified: 111 new tests (48 core + 59 modes + 4 install-root regression);
characterization + skin suites still exactly 94, unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(sports): stop dropping hockey and baseball events on optional feed keys
Both bugs were pinned AS-IS by the B0 characterization suite so this
phase could change them knowingly. Both fixes are adoptions of code the
corresponding plugins already ship, not new inventions.
Hockey: the extractor read competitor["statistics"] unguarded, so a
competitor arriving without that array raised KeyError inside the
generator and the WHOLE event was discarded -- valid scores and status
included. Shot/save counts now default to 0, which is already what the
suite expects for an empty statistics array.
Baseball: for live games the extractor read game_event["status"], the
event TOP-LEVEL status, to get the inning. Real ESPN events duplicate
status there, but MiLB events (synthesized from the MLB Stats API into
an ESPN-like shape) populate only the competition-level one, so the
lookup raised a bare KeyError and dropped the event. It now reads the
competition-level status that _extract_game_details_common has already
validated, so it cannot be missing at that point.
The two characterization tests that pinned the old behaviour are
rewritten to assert the fix rather than deleted, so the suite still
documents the edge case -- and still totals 94.
CHANGELOG records these plus the live-clock change from aaabc61 under
Changed/Fixed, since all three are user-visible. The two new promotion
suites join the CI unit job (449 tests).
Verified: unit job 449 passed, plugin-safety job 60 passed, and the
hockey (16) and baseball (24) plugin harnesses render clean at every
panel size.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(sports): harden the live game-over check and font/log init
Follow-up review findings on the promoted base-class methods.
_is_game_really_over:
- `period` present-but-None raised TypeError on `None >= FINAL_PERIOD`,
taking down the whole live-update pass (_detect_stale_games has no
try/except). Same failure shape as the null `period_text` already fixed.
- An expired clock spelled "00:00" normalizes to "0000", which matched
none of the hand-listed literals, so a finished game with a two-digit
minute clock stayed on the scoreboard forever. Compare numerically.
SportsCore:
- _load_fonts kept the cwd-relative "assets/fonts/..." literals the
_font_root() seam exists to remove, so every scoreboard font degraded
to PIL's default face outside the install root.
- _should_log read self._last_warning_time unguarded while only an
unrelated method initialized it lazily; the first warning of a run
raised AttributeError. Initialize it in __init__.
Also documents that game_update_timestamps is written by subclasses, not
by the base class, so the staleness branch is inert until B5 adoption.
14 new tests. Gates: 463 core unit, 60 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* feat(sports): opt-in celebration and rotation capabilities
Phase B2 of the sports unification. Both features exist in only some of
the nine scoreboards, so they ship as capabilities the plugin composes,
never as `if self.<feature>_enabled` branches inside the base classes: a
sport that does not opt in has none of this code in its MRO.
CelebrationMixin (afl, nrl, soccer, football)
The two lineages spelled this differently -- _check_for_goal /
celebrate_opponent_goals vs _check_for_score / celebrate_opponent_scores
-- but the bodies were identical apart from three things, each now a
seam rather than a branch:
- wording -> score_phrase() / win_phrase() hooks
- follow-up suppression -> COALESCE_SCORING_SEQUENCE, on for football
where a touchdown lands as +6 then +1, off where two increments are
two real goals
- team identity -> _favorite_key, so nrl matches on team id without
core learning why its abbreviations are ambiguous
Both config spellings are read, so a plugin adopting the mixin keeps
working with the keys already in its published schema.
Rotation strategies
The three "dialects" turned out to be one algorithm (SWRR) in two
shapes: an incremental picker holding state across calls, and a
precomputed per-cycle list. They agree within a cycle and differ only at
the boundary, so core ships both behind a name registry rather than
declaring a winner. weight_for is supplied by the host, so rotation.py
never learns what a favorite is; an unknown name degrades to "simple"
because it arrives from user config.
Each strategy is checked against a verbatim transcription of the plugin
code it replaces, over every live-game shape up to four games -- the
differential B5 will delete the bundled copies on the strength of.
185 new tests. Gates: 648 core unit, 60 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* feat(scroll): upstream the scroll orchestration layer; release 3.2.0
Phases B3 and B4.
B3 -- src/common/sports_scroll.py is deliberately NOT a superset of the
ten plugin scroll_display.py copies. A method-level comparison of the
eight that share a shape (f1 and ufc are genuine forks) found a sharp
split, and the module is drawn along it:
promoted orchestration -- get_all_vegas_content_items is identical
in all eight; clear_all, get_scroll_info,
get_dynamic_duration, is_complete and display_frame are
96-100% similar
promoted settings -- one algorithm; the copies differ only in which
league keys they walk, so the ladder is data
(SCROLL_LEAGUE_KEYS) rather than a body per sport
NOT content -- prepare_scroll_content has 8 distinct bodies
across 8 plugins (145 lines, 53% similar at worst) and
_load_separator_icons 7 (6% at worst)
Same name, different job: prepare_scroll_content draws *this sport's*
game card. Merging those eight bodies would be exactly the mistake the
promotion rule exists to prevent, so the base raises NotImplementedError
rather than rendering something plausible -- a base that rendered
something would let a plugin ship a silently blank scroll.
The one behavior added over the plugin copies is native
global_config['target_fps'] support. The bundled copies hardcode ~100
FPS via scroll_delay and never consult the global target; Part A
threaded it through each copy by hand, and this makes that threading
legacy compatibility rather than the mechanism.
66 tests, including three against the real ScrollHelper rather than a
double -- a suite built entirely on MagicMock would sail straight past a
rename in the helper.
B4 -- bump src/__init__.py to 3.2.0 and close the CHANGELOG's Unreleased
section against it. This is the number the sunset rule keys on: the
first core release shipping the unified sports library, and therefore
the floor a plugin sets ledmatrix_min_version to before deleting its
bundled copies. The version bump and the changelog release heading move
together on purpose -- separating them would leave a commit whose
changelog announces 3.2.0 while the code still reports 3.1.0.
Nothing here changes what an existing plugin loads; adoption is B5.
Gates: 714 core unit, 66 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(sports): per-type warning cooldowns and font-load logging
Follow-ups from the second review pass, both on already-fixed findings:
_should_log accepted a warning_type and ignored it, sharing one
timestamp across every kind of warning -- so an API-error warning
silenced an unrelated cache warning for the next minute, and whichever
fired first won. Cooldowns are now keyed by type. Nothing in core calls
this method, so no behavior regressed; _last_warning_time is kept in
step for subclasses that read it directly.
_load_fonts logged through the module-level logger, dropping the manager
context, and had no return type hint. It now uses self.logger (set well
before _load_fonts runs) and names the directory it searched -- the bare
"Fonts not found" sent people hunting for a font-format problem when the
actual cause is an install missing assets/fonts.
Gates: 717 core unit, 66 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* docs: record the validated hockey scroll-display pilot for B5
B5 cannot ship until this PR merges and 3.2.0 exists -- a plugin cannot
floor ledmatrix_min_version at a release that does not exist, and an
unguarded src.common.sports_scroll import would break every user on
3.1.0.
The pilot has been validated ahead of that gate: hockey's
scroll_display.py adopted against a core carrying 3.2.0 goes from 691 to
289 lines with all 16 harness renders byte-for-byte identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(sports): harden the B2/B3 capabilities against bad config and subclasses
Review pass on the phase B2-B4 changes. Every fix here is the same shape
as the crashes this PR already fixed in the hockey and baseball
extractors: a config or feed value that is present-but-wrong reaching
arithmetic or a comparison on a path with no guard.
celebrations:
- celebration_duration is coerced and floored at init. It is compared
numerically in display() *outside* any try block, so a string from a
hand-edited config propagated a TypeError straight out; zero or
negative armed a celebration that could never render.
- A render failure now disarms instead of staying armed. It previously
retried the same broken render on every frame for the rest of the
window -- a traceback per frame, and no scorebug either.
- prune_score_baselines() for the live set. Only _check_for_win removed
entries, so a game that left the live list any other way leaked its
baseline and the dict grew all season.
- display() reuses has_active_celebration() rather than repeating its
window comparison, and log lines carry a [Celebrations] prefix.
rotation:
- MAX_WEIGHT ceiling. A cycle is sum(weights) long and each step scans
every game, so an unbounded weight from a misread config spins the
display thread -- on a Pi that stalls rendering outright.
- register_rotation_strategy rejects a non-subclass factory at
registration instead of failing frames later inside schedule().
- schedule() previews through type(self), so a subclass overriding
next_game is previewed with its own ordering -- which is what the
method promises.
sports_scroll:
- scroll_speed / scroll_delay coerced. dict.get(key, default) only helps
when the key is absent; present-but-null reached the multiplication
inside __init__ and the display failed to construct at all.
- update_scroll_position and get_visible_portion moved inside the try.
They ran outside it, so a raise there reached the plugin's frame loop
despite the comment promising none can.
- prepare_and_display guards the subclass call, so one sport's bad
payload cannot take down the shared orchestration for the others.
- _current_game_type spells "nothing active" as "" in both classes; the
manager said None while the display said "".
Not taken: the report that baseball's favorite-team debug path still
reads event-level status. Verified against current code -- there are no
remaining game_event["status"] reads in that file; it was fixed in
2486bdb and the finding is stale.
Gates: 747 core unit, 66 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(baseball): don't drop favourite MiLB games on the diagnostic path
The competition-level status fallback fixed the inning lookup, but the
favourite-team debug block a few lines above still read the event top-level
game_event["status"]. MiLB events (synthesized from the MLB Stats API into an
ESPN-like shape) populate only the competition-level status, so the identical
event that extracted fine for a non-favourite raised KeyError and returned
None once the team was a favourite.
Worst possible shape for the bug: it only hit the games the user cared most
about, and only on the path meant to help diagnose them. The existing
regression test missed it because it never passes favourites, so
is_favorite_game was False and the block never ran.
Uses the validated competition-level `status`, which
_extract_game_details_common guarantees is present by that point. Adds a
favourites-passing companion test; confirmed it reproduces the KeyError
without the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
* docs(sports): type-hint game_update_timestamps to match its sibling
Addresses the last remaining sub-point on the modes.py review thread. The
design finding itself is already handled: the base class documents that it
only reads game_update_timestamps and that a subclass's update() owns writing
"last_seen" (and afl/etc. do, so stale-game eviction works in practice). The
one concrete gap was the missing annotation -- _zero_clock_timestamps is typed
Dict[str, float] while this nested map had none. Now Dict[str, Dict[str, float]].
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
* Address CodeRabbit review: font-name traversal + offline test guard
Two Minor findings from CodeRabbit's first review of this PR.
- resolve_font_path: reject relative font names carrying path components.
font_name comes from plugin config, which the web UI writes; a value like
"../../config/config.json" escaped assets/fonts/ after os.path.join and let
a config probe arbitrary paths for existence (disclosure unlikely, since
Pillow/freetype reject non-font files, but the probe is real). Relative
names must now be bare filenames (os.path.basename(name) == name); absolute
paths keep their existing isfile() gate. Test confirms the traversal
resolved the real config.json before the guard.
- build_manager fixture: patch requests.Session.get BEFORE constructing the
manager. Construction creates both SportsCore.session and the
ESPNDataSource.session; the old code only replaced manager.session after
the fact, leaving data_source.session real and able to reach the network on
an accidental fetch. Patching the class makes every session built in the
fixture offline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
* test(celebrations): make the expiry tests actually test expiry
CodeRabbit (Major) on the merge re-review: celebration_duration is clamped to
a 1.0s floor, so the two expiry tests that configured 0 and expected instant
expiration never actually hit the expiry branch. They passed only because
_draw_celebration_layout raises in the harness (no real fonts) and its
exception branch clears the celebration the same way -- so they were really
re-testing the render-failure path, not expiry.
Now use a valid 1s duration, backdate started_at past the window, and mock
_draw_celebration_layout with assert_not_called() so an expired celebration
provably does NOT render. Verified discriminating: both fail if
has_active_celebration is forced to never expire.
Production code unchanged -- the expiry logic was already correct; only the
tests were mismodelling it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
---------
Co-authored-by: Claude <noreply@anthropic.com>
1085 lines
56 KiB
Python
1085 lines
56 KiB
Python
"""The three display modes layered on SportsCore: SportsUpcoming,
|
|
SportsRecent and SportsLive. Split out of the former
|
|
``src/base_classes/sports.py``; see docs/SPORTS_UNIFICATION.md.
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from abc import abstractmethod
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Dict, List
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
from src.cache_manager import CacheManager
|
|
from src.display_manager import DisplayManager
|
|
|
|
from .core import SportsCore
|
|
|
|
|
|
class SportsUpcoming(SportsCore):
|
|
SKIN_MODE = "upcoming"
|
|
|
|
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
|
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
|
self.upcoming_games = [] # Store all fetched upcoming games initially
|
|
self.games_list = [] # Filtered list for display (favorite teams)
|
|
self.current_game_index = 0
|
|
self.last_update = 0
|
|
self.update_interval = self.mode_config.get("upcoming_update_interval", 3600) # Check for recent games every hour
|
|
self.last_log_time = 0
|
|
self.log_interval = 300
|
|
self.last_warning_time = 0
|
|
self.warning_cooldown = 300
|
|
self.last_game_switch = 0
|
|
self.game_display_duration = 15 # Display each upcoming game for 15 seconds
|
|
|
|
def _select_games_for_display(
|
|
self, processed_games: List[Dict], favorite_teams: List[str]
|
|
) -> List[Dict]:
|
|
"""
|
|
Single-pass game selection with proper deduplication and counting.
|
|
|
|
When a game involves two favorite teams, it counts toward BOTH teams' limits.
|
|
This prevents unexpected game counts from the multi-pass algorithm.
|
|
|
|
Team identity goes through the ``_favorite_key`` override point rather
|
|
than reading ``home_abbr``/``away_abbr`` directly, because abbreviations
|
|
are not unique in every league (NRL matches on team ID instead).
|
|
"""
|
|
sorted_games = sorted(
|
|
processed_games,
|
|
key=lambda g: g.get("start_time_utc")
|
|
or datetime.max.replace(tzinfo=timezone.utc),
|
|
)
|
|
|
|
if not favorite_teams:
|
|
return sorted_games
|
|
|
|
selected_games = []
|
|
selected_ids = set()
|
|
team_counts = {team: 0 for team in favorite_teams}
|
|
|
|
for game in sorted_games:
|
|
game_id = game.get("id")
|
|
if game_id in selected_ids:
|
|
continue
|
|
|
|
home = self._favorite_key(game, "home")
|
|
away = self._favorite_key(game, "away")
|
|
|
|
home_fav = home in favorite_teams
|
|
away_fav = away in favorite_teams
|
|
|
|
if not home_fav and not away_fav:
|
|
continue
|
|
|
|
home_needs = home_fav and team_counts[home] < self.upcoming_games_to_show
|
|
away_needs = away_fav and team_counts[away] < self.upcoming_games_to_show
|
|
|
|
if home_needs or away_needs:
|
|
selected_games.append(game)
|
|
selected_ids.add(game_id)
|
|
if home_fav:
|
|
team_counts[home] += 1
|
|
if away_fav:
|
|
team_counts[away] += 1
|
|
|
|
self.logger.debug(
|
|
f"Selected game {away}@{home}: team_counts={team_counts}"
|
|
)
|
|
|
|
if all(c >= self.upcoming_games_to_show for c in team_counts.values()):
|
|
self.logger.debug("All favorite teams satisfied, stopping selection")
|
|
break
|
|
|
|
self.logger.info(
|
|
f"Selected {len(selected_games)} games for {len(favorite_teams)} "
|
|
f"favorite teams: {team_counts}"
|
|
)
|
|
return selected_games
|
|
|
|
def update(self):
|
|
"""Update upcoming games data."""
|
|
if not self.is_enabled: return
|
|
current_time = time.time()
|
|
if current_time - self.last_update < self.update_interval:
|
|
return
|
|
|
|
self.last_update = current_time
|
|
|
|
# Fetch rankings if enabled
|
|
if self.show_ranking:
|
|
self._fetch_team_rankings()
|
|
|
|
try:
|
|
data = self._fetch_data() # Uses shared cache
|
|
if not data or 'events' not in data:
|
|
self.logger.warning("No events found in shared data.") # Changed log prefix
|
|
if not self.games_list: self.current_game = None
|
|
return
|
|
|
|
events = data['events']
|
|
# self.logger.info(f"Processing {len(events)} events from shared data.") # Changed log prefix
|
|
|
|
processed_games = []
|
|
favorite_games_found = 0
|
|
all_upcoming_games = 0 # Count all upcoming games regardless of favorites
|
|
|
|
for event in events:
|
|
game = self._extract_game_details(event)
|
|
# Count all upcoming games for debugging
|
|
if game and game['is_upcoming']:
|
|
all_upcoming_games += 1
|
|
|
|
# Filter criteria: must be upcoming ('pre' state)
|
|
if game and game['is_upcoming']:
|
|
# Only fetch odds for games that will be displayed
|
|
if self.show_favorite_teams_only:
|
|
if not self.favorite_teams:
|
|
continue
|
|
if game['home_abbr'] not in self.favorite_teams and game['away_abbr'] not in self.favorite_teams:
|
|
continue
|
|
processed_games.append(game)
|
|
# Count favorite team games for logging
|
|
if (game['home_abbr'] in self.favorite_teams or
|
|
game['away_abbr'] in self.favorite_teams):
|
|
favorite_games_found += 1
|
|
if self.show_odds:
|
|
self._fetch_odds(game)
|
|
|
|
# Enhanced logging for debugging
|
|
self.logger.info(f"Found {all_upcoming_games} total upcoming games in data")
|
|
self.logger.info(f"Found {len(processed_games)} upcoming games after filtering")
|
|
|
|
if processed_games:
|
|
for game in processed_games[:3]: # Show first 3
|
|
self.logger.info(f" {game['away_abbr']}@{game['home_abbr']} - {game['start_time_utc']}")
|
|
|
|
if self.favorite_teams and all_upcoming_games > 0:
|
|
self.logger.info(f"Favorite teams: {self.favorite_teams}")
|
|
self.logger.info(f"Found {favorite_games_found} favorite team upcoming games")
|
|
|
|
# Filter for favorite teams only if the config is set
|
|
if self.show_favorite_teams_only:
|
|
# Select N games per favorite team (where N = upcoming_games_to_show)
|
|
# Example: upcoming_games_to_show=2 with 3 favorite teams = up to 6 games total
|
|
team_games = []
|
|
for team in self.favorite_teams:
|
|
# Find games where this team is playing
|
|
if team_specific_games := [game for game in processed_games if game['home_abbr'] == team or game['away_abbr'] == team]:
|
|
# Sort by game time and take the earliest N games
|
|
team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
|
|
# Take up to upcoming_games_to_show games for this team
|
|
team_games.extend(team_specific_games[:self.upcoming_games_to_show])
|
|
|
|
# Sort the final list by game time (earliest first)
|
|
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
|
|
# Remove duplicates (in case a game involves multiple favorite teams)
|
|
seen_ids = set()
|
|
unique_team_games = []
|
|
for game in team_games:
|
|
if game['id'] not in seen_ids:
|
|
seen_ids.add(game['id'])
|
|
unique_team_games.append(game)
|
|
team_games = unique_team_games
|
|
else:
|
|
team_games = processed_games # Show all upcoming if no favorites
|
|
# Sort by game time, earliest first
|
|
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
|
|
# Limit to the specified number of upcoming games
|
|
team_games = team_games[:self.upcoming_games_to_show]
|
|
|
|
# Log changes or periodically
|
|
should_log = (
|
|
current_time - self.last_log_time >= self.log_interval or
|
|
len(team_games) != len(self.games_list) or
|
|
any(g1['id'] != g2.get('id') for g1, g2 in zip(self.games_list, team_games)) or
|
|
(not self.games_list and team_games)
|
|
)
|
|
|
|
# Check if the list of games to display has changed
|
|
new_game_ids = {g['id'] for g in team_games}
|
|
current_game_ids = {g['id'] for g in self.games_list}
|
|
|
|
if new_game_ids != current_game_ids:
|
|
self.logger.info(f"Found {len(team_games)} upcoming games within window for display.") # Changed log prefix
|
|
self.games_list = team_games
|
|
if not self.current_game or not self.games_list or self.current_game['id'] not in new_game_ids:
|
|
self.current_game_index = 0
|
|
self.current_game = self.games_list[0] if self.games_list else None
|
|
self.last_game_switch = current_time
|
|
else:
|
|
try:
|
|
self.current_game_index = next(i for i, g in enumerate(self.games_list) if g['id'] == self.current_game['id'])
|
|
self.current_game = self.games_list[self.current_game_index]
|
|
except StopIteration:
|
|
self.current_game_index = 0
|
|
self.current_game = self.games_list[0]
|
|
self.last_game_switch = current_time
|
|
|
|
elif self.games_list:
|
|
self.current_game = self.games_list[self.current_game_index] # Update data
|
|
|
|
if not self.games_list:
|
|
self.logger.info("No relevant upcoming games found to display.") # Changed log prefix
|
|
self.current_game = None
|
|
|
|
if should_log and not self.games_list:
|
|
# Log favorite teams only if no games are found and logging is needed
|
|
self.logger.debug(f"Favorite teams: {self.favorite_teams}") # Changed log prefix
|
|
self.logger.debug(f"Total upcoming games before filtering: {len(processed_games)}") # Changed log prefix
|
|
self.last_log_time = current_time
|
|
elif should_log:
|
|
self.last_log_time = current_time
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error updating upcoming games: {e}", exc_info=True) # Changed log prefix
|
|
# self.current_game = None # Decide if clear on error
|
|
|
|
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
|
"""Draw the layout for an upcoming NCAA FB game.""" # Updated docstring
|
|
try:
|
|
main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255))
|
|
overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0))
|
|
draw_overlay = ImageDraw.Draw(overlay)
|
|
|
|
home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url"))
|
|
away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url"))
|
|
|
|
if not home_logo or not away_logo:
|
|
self.logger.error(f"Failed to load logos for game: {game.get('id')}") # Changed log prefix
|
|
draw_final = ImageDraw.Draw(main_img.convert('RGB'))
|
|
self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status'])
|
|
self.display_manager.image.paste(main_img.convert('RGB'), (0, 0))
|
|
self.display_manager.update_display()
|
|
return
|
|
|
|
center_y = self.display_height // 2
|
|
|
|
# MLB-style logo positions
|
|
home_x = self.display_width - home_logo.width + 2
|
|
home_y = center_y - (home_logo.height // 2)
|
|
main_img.paste(home_logo, (home_x, home_y), home_logo)
|
|
|
|
away_x = -2
|
|
away_y = center_y - (away_logo.height // 2)
|
|
main_img.paste(away_logo, (away_x, away_y), away_logo)
|
|
|
|
# Draw Text Elements on Overlay
|
|
game_date = game.get("game_date", "")
|
|
game_time = game.get("game_time", "")
|
|
|
|
# Note: Rankings are now handled in the records/rankings section below
|
|
|
|
# "Next Game" at the top (use smaller status font)
|
|
status_font = self.fonts['status']
|
|
if self.display_width > 128:
|
|
status_font = self.fonts['time']
|
|
status_text = "Next Game"
|
|
status_width = draw_overlay.textlength(status_text, font=status_font)
|
|
status_x = (self.display_width - status_width) // 2
|
|
status_y = 1 # Changed from 2
|
|
self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), status_font)
|
|
|
|
# Date text (centered, below "Next Game")
|
|
date_width = draw_overlay.textlength(game_date, font=self.fonts['time'])
|
|
date_x = (self.display_width - date_width) // 2
|
|
# Adjust Y position to stack date and time nicely
|
|
date_y = center_y - 7 # Raise date slightly
|
|
self._draw_text_with_outline(draw_overlay, game_date, (date_x, date_y), self.fonts['time'])
|
|
|
|
# Time text (centered, below Date)
|
|
time_width = draw_overlay.textlength(game_time, font=self.fonts['time'])
|
|
time_x = (self.display_width - time_width) // 2
|
|
time_y = date_y + 9 # Place time below date
|
|
self._draw_text_with_outline(draw_overlay, game_time, (time_x, time_y), self.fonts['time'])
|
|
|
|
# Draw odds if available
|
|
if 'odds' in game and game['odds']:
|
|
self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height)
|
|
|
|
# Draw records or rankings if enabled
|
|
if self.show_records or self.show_ranking:
|
|
record_font = self.fonts.get('detail', ImageFont.load_default())
|
|
|
|
# Get team abbreviations
|
|
away_abbr = game.get('away_abbr', '')
|
|
home_abbr = game.get('home_abbr', '')
|
|
|
|
record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font)
|
|
record_height = record_bbox[3] - record_bbox[1]
|
|
record_y = self.display_height - record_height
|
|
self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}")
|
|
|
|
# Display away team info
|
|
if away_abbr:
|
|
if self.show_ranking and self.show_records:
|
|
# When both rankings and records are enabled, rankings replace records completely
|
|
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
|
if away_rank > 0:
|
|
away_text = f"#{away_rank}"
|
|
else:
|
|
# Show nothing for unranked teams when rankings are prioritized
|
|
away_text = ''
|
|
elif self.show_ranking:
|
|
# Show ranking only if available
|
|
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
|
if away_rank > 0:
|
|
away_text = f"#{away_rank}"
|
|
else:
|
|
away_text = ''
|
|
elif self.show_records:
|
|
# Show record only when rankings are disabled
|
|
away_text = game.get('away_record', '')
|
|
else:
|
|
away_text = ''
|
|
|
|
if away_text:
|
|
away_record_x = 0
|
|
self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}")
|
|
self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font)
|
|
|
|
# Display home team info
|
|
if home_abbr:
|
|
if self.show_ranking and self.show_records:
|
|
# When both rankings and records are enabled, rankings replace records completely
|
|
home_rank = self._team_rankings_cache.get(home_abbr, 0)
|
|
if home_rank > 0:
|
|
home_text = f"#{home_rank}"
|
|
else:
|
|
# Show nothing for unranked teams when rankings are prioritized
|
|
home_text = ''
|
|
elif self.show_ranking:
|
|
# Show ranking only if available
|
|
home_rank = self._team_rankings_cache.get(home_abbr, 0)
|
|
if home_rank > 0:
|
|
home_text = f"#{home_rank}"
|
|
else:
|
|
home_text = ''
|
|
elif self.show_records:
|
|
# Show record only when rankings are disabled
|
|
home_text = game.get('home_record', '')
|
|
else:
|
|
home_text = ''
|
|
|
|
if home_text:
|
|
home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font)
|
|
home_record_width = home_record_bbox[2] - home_record_bbox[0]
|
|
home_record_x = self.display_width - home_record_width
|
|
self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}")
|
|
self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font)
|
|
|
|
# Composite and display
|
|
main_img = Image.alpha_composite(main_img, overlay)
|
|
main_img = main_img.convert('RGB')
|
|
self.display_manager.image.paste(main_img, (0, 0))
|
|
self.display_manager.update_display() # Update display here
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error displaying upcoming game: {e}", exc_info=True) # Changed log prefix
|
|
|
|
def display(self, force_clear=False) -> bool:
|
|
"""Display upcoming games, handling switching."""
|
|
if not self.is_enabled: return False
|
|
|
|
if not self.games_list:
|
|
if self.current_game: self.current_game = None # Clear state if list empty
|
|
current_time = time.time()
|
|
# Log warning periodically if no games found
|
|
if current_time - self.last_warning_time > self.warning_cooldown:
|
|
self.logger.info("No upcoming games found for favorite teams to display.") # Changed log prefix
|
|
self.last_warning_time = current_time
|
|
return False # Skip display update
|
|
|
|
try:
|
|
current_time = time.time()
|
|
|
|
# Check if it's time to switch games
|
|
if len(self.games_list) > 1 and current_time - self.last_game_switch >= self.game_display_duration:
|
|
self.current_game_index = (self.current_game_index + 1) % len(self.games_list)
|
|
self.current_game = self.games_list[self.current_game_index]
|
|
self.last_game_switch = current_time
|
|
force_clear = True # Force redraw on switch
|
|
|
|
# Log team switching with sport prefix
|
|
if self.current_game:
|
|
away_abbr = self.current_game.get('away_abbr', 'UNK')
|
|
home_abbr = self.current_game.get('home_abbr', 'UNK')
|
|
sport_prefix = self.sport_key.upper() if hasattr(self, 'sport_key') else 'SPORT'
|
|
self.logger.info(f"[{sport_prefix} Upcoming] Showing {away_abbr} vs {home_abbr}")
|
|
else:
|
|
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
|
|
|
if self.current_game:
|
|
self._render_game(self.current_game, force_clear)
|
|
return True
|
|
# update_display() is called within _draw_scorebug_layout for upcoming
|
|
return False
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error in display loop: {e}", exc_info=True) # Changed log prefix
|
|
return False
|
|
|
|
|
|
class SportsRecent(SportsCore):
|
|
SKIN_MODE = "recent"
|
|
|
|
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
|
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
|
self.recent_games = [] # Store all fetched recent games initially
|
|
self.games_list = [] # Filtered list for display (favorite teams)
|
|
self.current_game_index = 0
|
|
self.last_update = 0
|
|
self.update_interval = self.mode_config.get("recent_update_interval", 3600) # Check for recent games every hour
|
|
self.last_game_switch = 0
|
|
self.game_display_duration = 15 # Display each recent game for 15 seconds
|
|
# Tracks when each game was first seen with an expired clock, keyed by
|
|
# game id. Promoted alongside the zero-clock helpers below; without it
|
|
# the first _get_zero_clock_duration() call raises AttributeError.
|
|
self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00
|
|
|
|
# -- Zero-clock tracking ------------------------------------------------
|
|
# Byte-identical in all nine plugin copies. Note that afl/nrl/soccer define
|
|
# these but never call them — their clocks count up, so 0:00 means kickoff
|
|
# rather than expiry (see CLOCK_COUNTS_DOWN on SportsLive). That makes the
|
|
# pair a future `CountdownClockMixin` candidate so it stops appearing in the
|
|
# MRO of sports that cannot use it — B2 work, not now.
|
|
|
|
def _get_zero_clock_duration(self, game_id: str) -> float:
|
|
"""Track how long a game has been at 0:00 clock."""
|
|
current_time = time.time()
|
|
if game_id not in self._zero_clock_timestamps:
|
|
self._zero_clock_timestamps[game_id] = current_time
|
|
return 0.0
|
|
return current_time - self._zero_clock_timestamps[game_id]
|
|
|
|
def _clear_zero_clock_tracking(self, game_id: str) -> None:
|
|
"""Clear tracking when game clock moves away from 0:00 or game ends."""
|
|
if game_id in self._zero_clock_timestamps:
|
|
del self._zero_clock_timestamps[game_id]
|
|
|
|
def _select_recent_games_for_display(
|
|
self, processed_games: List[Dict], favorite_teams: List[str]
|
|
) -> List[Dict]:
|
|
"""
|
|
Single-pass game selection for recent games with proper deduplication.
|
|
|
|
When a game involves two favorite teams, it counts toward BOTH teams' limits.
|
|
Games are sorted by most recent first.
|
|
|
|
Team identity goes through the ``_favorite_key`` override point rather
|
|
than reading ``home_abbr``/``away_abbr`` directly, because abbreviations
|
|
are not unique in every league (NRL matches on team ID instead).
|
|
"""
|
|
sorted_games = sorted(
|
|
processed_games,
|
|
key=lambda g: g.get("start_time_utc")
|
|
or datetime.min.replace(tzinfo=timezone.utc),
|
|
reverse=True,
|
|
)
|
|
|
|
if not favorite_teams:
|
|
return sorted_games
|
|
|
|
selected_games = []
|
|
selected_ids = set()
|
|
team_counts = {team: 0 for team in favorite_teams}
|
|
|
|
for game in sorted_games:
|
|
game_id = game.get("id")
|
|
if game_id in selected_ids:
|
|
continue
|
|
|
|
home = self._favorite_key(game, "home")
|
|
away = self._favorite_key(game, "away")
|
|
|
|
home_fav = home in favorite_teams
|
|
away_fav = away in favorite_teams
|
|
|
|
if not home_fav and not away_fav:
|
|
continue
|
|
|
|
home_needs = home_fav and team_counts[home] < self.recent_games_to_show
|
|
away_needs = away_fav and team_counts[away] < self.recent_games_to_show
|
|
|
|
if home_needs or away_needs:
|
|
selected_games.append(game)
|
|
selected_ids.add(game_id)
|
|
if home_fav:
|
|
team_counts[home] += 1
|
|
if away_fav:
|
|
team_counts[away] += 1
|
|
|
|
self.logger.debug(
|
|
f"Selected recent game {away}@{home}: team_counts={team_counts}"
|
|
)
|
|
|
|
if all(c >= self.recent_games_to_show for c in team_counts.values()):
|
|
self.logger.debug("All favorite teams satisfied, stopping selection")
|
|
break
|
|
|
|
self.logger.info(
|
|
f"Selected {len(selected_games)} recent games for {len(favorite_teams)} "
|
|
f"favorite teams: {team_counts}"
|
|
)
|
|
return selected_games
|
|
|
|
def update(self):
|
|
"""Update recent games data."""
|
|
if not self.is_enabled: return
|
|
current_time = time.time()
|
|
if current_time - self.last_update < self.update_interval:
|
|
return
|
|
|
|
self.last_update = current_time # Update time even if fetch fails
|
|
|
|
# Fetch rankings if enabled
|
|
if self.show_ranking:
|
|
self._fetch_team_rankings()
|
|
|
|
try:
|
|
data = self._fetch_data() # Uses shared cache
|
|
if not data or 'events' not in data:
|
|
self.logger.warning("No events found in shared data.") # Changed log prefix
|
|
if not self.games_list:
|
|
self.current_game = None # Clear display if no games were showing
|
|
return
|
|
|
|
events = data['events']
|
|
self.logger.info(f"Processing {len(events)} events from shared data.") # Changed log prefix
|
|
|
|
# Define date range for "recent" games (last 21 days to capture games from 3 weeks ago)
|
|
now = datetime.now(timezone.utc)
|
|
recent_cutoff = now - timedelta(days=21)
|
|
self.logger.info(f"Current time: {now}, Recent cutoff: {recent_cutoff} (21 days ago)")
|
|
|
|
# Process games and filter for final games, date range & favorite teams
|
|
processed_games = []
|
|
for event in events:
|
|
game = self._extract_game_details(event)
|
|
# Filter criteria: must be final AND within recent date range
|
|
if game and game['is_final']:
|
|
game_time = game.get('start_time_utc')
|
|
if game_time and game_time >= recent_cutoff:
|
|
processed_games.append(game)
|
|
# Filter for favorite teams only if the config is set
|
|
if self.show_favorite_teams_only:
|
|
# Get all games involving favorite teams
|
|
favorite_team_games = [game for game in processed_games
|
|
if game['home_abbr'] in self.favorite_teams or
|
|
game['away_abbr'] in self.favorite_teams]
|
|
self.logger.info(f"Found {len(favorite_team_games)} favorite team games out of {len(processed_games)} total final games within last 21 days")
|
|
|
|
# Select N games per favorite team (where N = recent_games_to_show)
|
|
# Example: recent_games_to_show=1 with 2 favorite teams = 2 games total
|
|
team_games = []
|
|
for team in self.favorite_teams:
|
|
# Find games where this team is playing
|
|
team_specific_games = [game for game in favorite_team_games
|
|
if game['home_abbr'] == team or game['away_abbr'] == team]
|
|
|
|
if team_specific_games:
|
|
# Sort by game time and take the most recent N games
|
|
team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
|
# Take up to recent_games_to_show games for this team
|
|
team_games.extend(team_specific_games[:self.recent_games_to_show])
|
|
|
|
# Sort the final list by game time (most recent first)
|
|
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
|
# Remove duplicates (in case a game involves multiple favorite teams)
|
|
seen_ids = set()
|
|
unique_team_games = []
|
|
for game in team_games:
|
|
if game['id'] not in seen_ids:
|
|
seen_ids.add(game['id'])
|
|
unique_team_games.append(game)
|
|
team_games = unique_team_games
|
|
|
|
# Debug: Show which games are selected for display
|
|
for i, game in enumerate(team_games):
|
|
self.logger.info(f"Game {i+1} for display: {game['away_abbr']} @ {game['home_abbr']} - {game.get('start_time_utc')} - Score: {game['away_score']}-{game['home_score']}")
|
|
else:
|
|
team_games = processed_games # Show all recent games if no favorites defined
|
|
self.logger.info(f"Found {len(processed_games)} total final games within last 21 days (no favorite teams filtering)")
|
|
# Sort games by start time, most recent first, and limit to recent_games_to_show
|
|
team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
|
team_games = team_games[:self.recent_games_to_show]
|
|
|
|
# Check if the list of games to display has changed
|
|
new_game_ids = {g['id'] for g in team_games}
|
|
current_game_ids = {g['id'] for g in self.games_list}
|
|
|
|
if new_game_ids != current_game_ids:
|
|
self.logger.info(f"Found {len(team_games)} final games within window for display.") # Changed log prefix
|
|
self.games_list = team_games
|
|
# Reset index if list changed or current game removed
|
|
if not self.current_game or not self.games_list or self.current_game['id'] not in new_game_ids:
|
|
self.current_game_index = 0
|
|
self.current_game = self.games_list[0] if self.games_list else None
|
|
self.last_game_switch = current_time # Reset switch timer
|
|
else:
|
|
# Try to maintain position if possible
|
|
try:
|
|
self.current_game_index = next(i for i, g in enumerate(self.games_list) if g['id'] == self.current_game['id'])
|
|
self.current_game = self.games_list[self.current_game_index] # Update data just in case
|
|
except StopIteration:
|
|
self.current_game_index = 0
|
|
self.current_game = self.games_list[0]
|
|
self.last_game_switch = current_time
|
|
|
|
elif self.games_list:
|
|
# List content is same, just update data for current game
|
|
self.current_game = self.games_list[self.current_game_index]
|
|
|
|
if not self.games_list:
|
|
self.logger.info("No relevant recent games found to display.") # Changed log prefix
|
|
self.current_game = None # Ensure display clears if no games
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error updating recent games: {e}", exc_info=True) # Changed log prefix
|
|
# Don't clear current game on error, keep showing last known state
|
|
# self.current_game = None # Decide if we want to clear display on error
|
|
|
|
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
|
"""Draw the layout for a recently completed NCAA FB game.""" # Updated docstring
|
|
try:
|
|
main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255))
|
|
overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0))
|
|
draw_overlay = ImageDraw.Draw(overlay)
|
|
|
|
home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url"))
|
|
away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url"))
|
|
|
|
if not home_logo or not away_logo:
|
|
self.logger.error(f"Failed to load logos for game: {game.get('id')}") # Changed log prefix
|
|
# Draw placeholder text if logos fail (similar to live)
|
|
draw_final = ImageDraw.Draw(main_img.convert('RGB'))
|
|
self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status'])
|
|
self.display_manager.image.paste(main_img.convert('RGB'), (0, 0))
|
|
self.display_manager.update_display()
|
|
return
|
|
|
|
center_y = self.display_height // 2
|
|
|
|
# MLB-style logo positioning (closer to edges)
|
|
home_x = self.display_width - home_logo.width + 2
|
|
home_y = center_y - (home_logo.height // 2)
|
|
main_img.paste(home_logo, (home_x, home_y), home_logo)
|
|
|
|
away_x = -2
|
|
away_y = center_y - (away_logo.height // 2)
|
|
main_img.paste(away_logo, (away_x, away_y), away_logo)
|
|
|
|
# Draw Text Elements on Overlay
|
|
# Note: Rankings are now handled in the records/rankings section below
|
|
|
|
# Final Scores (Centered, same position as live)
|
|
home_score = str(game.get("home_score", "0"))
|
|
away_score = str(game.get("away_score", "0"))
|
|
score_text = f"{away_score}-{home_score}"
|
|
score_width = draw_overlay.textlength(score_text, font=self.fonts['score'])
|
|
score_x = (self.display_width - score_width) // 2
|
|
score_y = self.display_height - 14
|
|
self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score'])
|
|
|
|
# "Final" text (Top center)
|
|
status_text = game.get("period_text", "Final") # Use formatted period text (e.g., "Final/OT") or default "Final"
|
|
status_width = draw_overlay.textlength(status_text, font=self.fonts['time'])
|
|
status_x = (self.display_width - status_width) // 2
|
|
status_y = 1
|
|
self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), self.fonts['time'])
|
|
|
|
# Draw odds if available
|
|
if 'odds' in game and game['odds']:
|
|
self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height)
|
|
|
|
# Draw records or rankings if enabled
|
|
if self.show_records or self.show_ranking:
|
|
record_font = self.fonts.get('detail', ImageFont.load_default())
|
|
|
|
# Get team abbreviations
|
|
away_abbr = game.get('away_abbr', '')
|
|
home_abbr = game.get('home_abbr', '')
|
|
|
|
record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font)
|
|
record_height = record_bbox[3] - record_bbox[1]
|
|
record_y = self.display_height - record_height
|
|
self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}")
|
|
|
|
# Display away team info
|
|
if away_abbr:
|
|
if self.show_ranking and self.show_records:
|
|
# When both rankings and records are enabled, rankings replace records completely
|
|
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
|
if away_rank > 0:
|
|
away_text = f"#{away_rank}"
|
|
else:
|
|
# Show nothing for unranked teams when rankings are prioritized
|
|
away_text = ''
|
|
elif self.show_ranking:
|
|
# Show ranking only if available
|
|
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
|
if away_rank > 0:
|
|
away_text = f"#{away_rank}"
|
|
else:
|
|
away_text = ''
|
|
elif self.show_records:
|
|
# Show record only when rankings are disabled
|
|
away_text = game.get('away_record', '')
|
|
else:
|
|
away_text = ''
|
|
|
|
if away_text:
|
|
away_record_x = 0
|
|
self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}")
|
|
self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font)
|
|
|
|
# Display home team info
|
|
if home_abbr:
|
|
if self.show_ranking and self.show_records:
|
|
# When both rankings and records are enabled, rankings replace records completely
|
|
home_rank = self._team_rankings_cache.get(home_abbr, 0)
|
|
if home_rank > 0:
|
|
home_text = f"#{home_rank}"
|
|
else:
|
|
# Show nothing for unranked teams when rankings are prioritized
|
|
home_text = ''
|
|
elif self.show_ranking:
|
|
# Show ranking only if available
|
|
home_rank = self._team_rankings_cache.get(home_abbr, 0)
|
|
if home_rank > 0:
|
|
home_text = f"#{home_rank}"
|
|
else:
|
|
home_text = ''
|
|
elif self.show_records:
|
|
# Show record only when rankings are disabled
|
|
home_text = game.get('home_record', '')
|
|
else:
|
|
home_text = ''
|
|
|
|
if home_text:
|
|
home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font)
|
|
home_record_width = home_record_bbox[2] - home_record_bbox[0]
|
|
home_record_x = self.display_width - home_record_width
|
|
self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}")
|
|
self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font)
|
|
|
|
self._custom_scorebug_layout(game, draw_overlay)
|
|
# Composite and display
|
|
main_img = Image.alpha_composite(main_img, overlay)
|
|
main_img = main_img.convert('RGB')
|
|
self.display_manager.image.paste(main_img, (0, 0))
|
|
self.display_manager.update_display() # Update display here
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error displaying recent game: {e}", exc_info=True) # Changed log prefix
|
|
|
|
def display(self, force_clear=False) -> bool:
|
|
"""Display recent games, handling switching."""
|
|
if not self.is_enabled or not self.games_list:
|
|
# If disabled or no games, ensure display might be cleared by main loop if needed
|
|
# Or potentially clear it here? For now, rely on main loop/other managers.
|
|
if not self.games_list and self.current_game:
|
|
self.current_game = None # Clear internal state if list becomes empty
|
|
return False
|
|
|
|
try:
|
|
current_time = time.time()
|
|
|
|
# Check if it's time to switch games
|
|
if len(self.games_list) > 1 and current_time - self.last_game_switch >= self.game_display_duration:
|
|
self.current_game_index = (self.current_game_index + 1) % len(self.games_list)
|
|
self.current_game = self.games_list[self.current_game_index]
|
|
self.last_game_switch = current_time
|
|
force_clear = True # Force redraw on switch
|
|
|
|
# Log team switching with sport prefix
|
|
if self.current_game:
|
|
away_abbr = self.current_game.get('away_abbr', 'UNK')
|
|
home_abbr = self.current_game.get('home_abbr', 'UNK')
|
|
sport_prefix = self.sport_key.upper() if hasattr(self, 'sport_key') else 'SPORT'
|
|
self.logger.info(f"[{sport_prefix} Recent] Showing {away_abbr} vs {home_abbr}")
|
|
else:
|
|
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
|
|
|
if self.current_game:
|
|
self._render_game(self.current_game, force_clear)
|
|
return True
|
|
# update_display() is called within _draw_scorebug_layout for recent
|
|
return False
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error in display loop: {e}", exc_info=True) # Changed log prefix
|
|
return False
|
|
|
|
class SportsLive(SportsCore):
|
|
# Per-sport constants for the "is this live game actually over?" check.
|
|
# These are values, not behavior, so they are class attributes rather than
|
|
# override points (see docs/SPORTS_UNIFICATION.md "Override points").
|
|
#
|
|
# FINAL_PERIOD: the period at/after which an expired clock can mean "over".
|
|
# 4 for four-quarter sports; hockey overrides to 3.
|
|
# CLOCK_COUNTS_DOWN: whether "0:00" means the clock expired. False for
|
|
# sports whose clock counts up (soccer/afl/nrl), where 0:00 is kickoff —
|
|
# running the expiry branch there would evict games that just started.
|
|
FINAL_PERIOD = 4
|
|
CLOCK_COUNTS_DOWN = True
|
|
|
|
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
|
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
|
|
self.last_update = 0
|
|
self.live_games = []
|
|
self.current_game_index = 0
|
|
self.last_game_switch = 0 # Will be set to current_time when games are first loaded
|
|
self.game_display_duration = self.mode_config.get("live_game_duration", 20)
|
|
self.last_display_update = 0
|
|
self.last_log_time = 0
|
|
self.log_interval = 300
|
|
self.last_count_log_time = 0 # Track when we last logged count data
|
|
self.count_log_interval = 5 # Only log count data every 5 seconds
|
|
# Initialize test_mode - defaults to False (live mode)
|
|
self.test_mode = self.mode_config.get("test_mode", False)
|
|
# Freshness bookkeeping for _detect_stale_games(). The base class only
|
|
# *reads* this map; a subclass's update() stamps entries as it ingests a
|
|
# feed: {game_id: {"clock": ts, "score": ts, "last_seen": ts}}.
|
|
# Until a subclass writes "last_seen", the staleness branch of
|
|
# _detect_stale_games is inert and only the game-over check applies.
|
|
self.game_update_timestamps: Dict[str, Dict[str, float]] = {}
|
|
self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default
|
|
|
|
@abstractmethod
|
|
def _test_mode_update(self) -> None:
|
|
return
|
|
|
|
def _is_game_really_over(self, game: Dict) -> bool:
|
|
"""Check if a game appears to be over even if API says it's live.
|
|
|
|
Two independent signals:
|
|
1. ``period_text`` says "final" — universal across every sport.
|
|
2. The clock has expired at/after :attr:`FINAL_PERIOD` — only meaningful
|
|
where :attr:`CLOCK_COUNTS_DOWN` is true.
|
|
|
|
Fails *safe*: anything ambiguous returns False and the game keeps being
|
|
displayed. The only caller, :meth:`_detect_stale_games`, removes games
|
|
on a True, so a false positive silently drops a live game.
|
|
"""
|
|
game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}"
|
|
|
|
# `period_text` may be present-but-None; `or ""` keeps that from raising
|
|
# AttributeError — the caller has no try/except around this call.
|
|
period_text = (game.get("period_text") or "").lower()
|
|
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
|
|
|
|
if not self.CLOCK_COUNTS_DOWN:
|
|
# Count-up clock: 0:00 means the match has not started.
|
|
self.logger.debug(
|
|
f"_is_game_really_over({game_str}): returning False "
|
|
f"(count-up clock, period_text='{period_text}')"
|
|
)
|
|
return False
|
|
|
|
raw_clock = game.get("clock")
|
|
# `or 0` rather than a get() default: feeds routinely send an explicit
|
|
# null period, and `None >= FINAL_PERIOD` raises TypeError — which would
|
|
# take down the whole live-update pass, since the only caller
|
|
# (_detect_stale_games) has no try/except around it.
|
|
period = game.get("period") or 0
|
|
|
|
# Only check clock-based finish if we have a valid clock string. A
|
|
# missing or non-string clock is NOT coerced to "0:00": sports without a
|
|
# game clock (e.g. baseball, where `period` is the inning) would
|
|
# otherwise be declared over from the FINAL_PERIOD-th period onward.
|
|
if isinstance(raw_clock, str) and raw_clock.strip() and period >= self.FINAL_PERIOD:
|
|
clock = raw_clock
|
|
# Compare numerically rather than against a literal set: feeds spell
|
|
# an expired clock "0:00", ":00" and "00:00" depending on sport, and
|
|
# a membership test silently misses every spelling not listed.
|
|
clock_normalized = clock.replace(":", "").strip()
|
|
if clock_normalized.isdigit() and int(clock_normalized) == 0:
|
|
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}): returning False"
|
|
)
|
|
return False
|
|
|
|
def _detect_stale_games(self, games: List[Dict]) -> None:
|
|
"""Remove games that appear stale or haven't updated.
|
|
|
|
Mutates ``games`` **in place** and returns None. Removal is by value
|
|
(``list.remove`` uses ``dict.__eq__``), so two structurally-equal game
|
|
dicts in the same list would drop the first occurrence.
|
|
"""
|
|
current_time = time.time()
|
|
|
|
for game in games[:]: # Copy list to iterate safely
|
|
game_id = game.get("id")
|
|
if not game_id:
|
|
continue
|
|
|
|
# Check if game data is stale
|
|
timestamps = self.game_update_timestamps.get(game_id, {})
|
|
last_seen = timestamps.get("last_seen", 0)
|
|
|
|
if last_seen > 0 and current_time - last_seen > self.stale_game_timeout:
|
|
self.logger.warning(
|
|
f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} "
|
|
f"(last seen {int(current_time - last_seen)}s ago)"
|
|
)
|
|
games.remove(game)
|
|
if game_id in self.game_update_timestamps:
|
|
del self.game_update_timestamps[game_id]
|
|
continue
|
|
|
|
# Also check if game appears to be over
|
|
if self._is_game_really_over(game):
|
|
self.logger.debug(
|
|
f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} "
|
|
f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})"
|
|
)
|
|
games.remove(game)
|
|
if game_id in self.game_update_timestamps:
|
|
del self.game_update_timestamps[game_id]
|
|
|
|
def update(self):
|
|
"""Update live game data and handle game switching."""
|
|
if not self.is_enabled:
|
|
return
|
|
|
|
# Define current_time and interval before the problematic line (originally line 455)
|
|
# Ensure 'import time' is present at the top of the file.
|
|
current_time = time.time()
|
|
|
|
# Define interval using a pattern similar to NFLLiveManager's update method.
|
|
# Uses getattr for robustness, assuming attributes for live_games, test_mode,
|
|
# no_data_interval, and update_interval are available on self.
|
|
_live_games_attr = self.live_games
|
|
_test_mode_attr = self.test_mode # test_mode is often from a base class or config
|
|
_no_data_interval_attr = self.no_data_interval # Default similar to NFLLiveManager
|
|
_update_interval_attr = self.update_interval # Default similar to NFLLiveManager
|
|
|
|
interval = _no_data_interval_attr if not _live_games_attr and not _test_mode_attr else _update_interval_attr
|
|
|
|
# Original line from traceback (line 455), now with variables defined:
|
|
if current_time - self.last_update >= interval:
|
|
self.last_update = current_time
|
|
|
|
# Fetch rankings if enabled
|
|
if self.show_ranking:
|
|
self._fetch_team_rankings()
|
|
|
|
if self.test_mode:
|
|
# Simulate clock running down in test mode
|
|
self._test_mode_update()
|
|
else:
|
|
# Fetch live game data
|
|
data = self._fetch_data()
|
|
new_live_games = []
|
|
if data and "events" in data:
|
|
for game in data["events"]:
|
|
details = self._extract_game_details(game)
|
|
if details and (details["is_live"] or details["is_halftime"]):
|
|
# If show_favorite_teams_only is true, only add if it's a favorite.
|
|
# Otherwise, add all games.
|
|
if self.show_all_live or not self.show_favorite_teams_only or (self.show_favorite_teams_only and (details["home_abbr"] in self.favorite_teams or details["away_abbr"] in self.favorite_teams)):
|
|
if self.show_odds:
|
|
self._fetch_odds(details)
|
|
new_live_games.append(details)
|
|
# Log changes or periodically
|
|
current_time_for_log = time.time() # Use a consistent time for logging comparison
|
|
should_log = (
|
|
current_time_for_log - self.last_log_time >= self.log_interval or
|
|
len(new_live_games) != len(self.live_games) or
|
|
any(g1['id'] != g2.get('id') for g1, g2 in zip(self.live_games, new_live_games)) or # Check if game IDs changed
|
|
(not self.live_games and new_live_games) # Log if games appeared
|
|
)
|
|
|
|
if should_log:
|
|
if new_live_games:
|
|
filter_text = "favorite teams" if self.show_favorite_teams_only or self.show_all_live else "all teams"
|
|
self.logger.info(f"Found {len(new_live_games)} live/halftime games for {filter_text}.")
|
|
for game_info in new_live_games: # Renamed game to game_info
|
|
self.logger.info(f" - {game_info['away_abbr']}@{game_info['home_abbr']} ({game_info.get('status_text', 'N/A')})")
|
|
else:
|
|
filter_text = "favorite teams" if self.show_favorite_teams_only or self.show_all_live else "criteria"
|
|
self.logger.info(f"No live/halftime games found for {filter_text}.")
|
|
self.last_log_time = current_time_for_log
|
|
|
|
|
|
# Update game list and current game
|
|
if new_live_games:
|
|
# Check if the games themselves changed, not just scores/time
|
|
new_game_ids = {g['id'] for g in new_live_games}
|
|
current_game_ids = {g['id'] for g in self.live_games}
|
|
|
|
if new_game_ids != current_game_ids:
|
|
self.live_games = sorted(new_live_games, key=lambda g: g.get('start_time_utc') or datetime.now(timezone.utc)) # Sort by start time
|
|
# Reset index if current game is gone or list is new
|
|
if not self.current_game or self.current_game['id'] not in new_game_ids:
|
|
self.current_game_index = 0
|
|
self.current_game = self.live_games[0] if self.live_games else None
|
|
self.last_game_switch = current_time
|
|
else:
|
|
# Find current game's new index if it still exists
|
|
try:
|
|
self.current_game_index = next(i for i, g in enumerate(self.live_games) if g['id'] == self.current_game['id'])
|
|
self.current_game = self.live_games[self.current_game_index] # Update current_game with fresh data
|
|
# Fix: Set last_game_switch if it's still 0 (initialized) to prevent immediate switching
|
|
if self.last_game_switch == 0:
|
|
self.last_game_switch = current_time
|
|
except StopIteration: # Should not happen if check above passed, but safety first
|
|
self.current_game_index = 0
|
|
self.current_game = self.live_games[0]
|
|
self.last_game_switch = current_time
|
|
|
|
else:
|
|
# Just update the data for the existing games
|
|
temp_game_dict = {g['id']: g for g in new_live_games}
|
|
self.live_games = [temp_game_dict.get(g['id'], g) for g in self.live_games] # Update in place
|
|
if self.current_game:
|
|
self.current_game = temp_game_dict.get(self.current_game['id'], self.current_game)
|
|
# Fix: Set last_game_switch if it's still 0 (initialized) to prevent immediate switching
|
|
# This handles the case where games were loaded previously but last_game_switch was never set
|
|
if self.last_game_switch == 0:
|
|
self.last_game_switch = current_time
|
|
|
|
# Display update handled by main loop based on interval
|
|
|
|
else:
|
|
# No live games found
|
|
if self.live_games: # Were there games before?
|
|
self.logger.info("Live games previously showing have ended or are no longer live.") # Changed log prefix
|
|
self.live_games = []
|
|
self.current_game = None
|
|
self.current_game_index = 0
|
|
|
|
else:
|
|
# Error fetching data or no events
|
|
if self.live_games: # Were there games before?
|
|
self.logger.warning("Could not fetch update; keeping existing live game data for now.") # Changed log prefix
|
|
else:
|
|
self.logger.warning("Could not fetch data and no existing live games.") # Changed log prefix
|
|
self.current_game = None # Clear current game if fetch fails and no games were active
|
|
|
|
# Handle game switching (outside test mode check)
|
|
# Fix: Don't check for switching if last_game_switch is still 0 (games haven't been loaded yet)
|
|
# This prevents immediate switching when the system has been running for a while before games load
|
|
if not self.test_mode and len(self.live_games) > 1 and self.last_game_switch > 0 and (current_time - self.last_game_switch) >= self.game_display_duration:
|
|
self.current_game_index = (self.current_game_index + 1) % len(self.live_games)
|
|
self.current_game = self.live_games[self.current_game_index]
|
|
self.last_game_switch = current_time
|
|
self.logger.info(f"Switched live view to: {self.current_game['away_abbr']}@{self.current_game['home_abbr']}") # Changed log prefix
|
|
# Force display update via flag or direct call if needed, but usually let main loop handle
|