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
182 changes: 134 additions & 48 deletions plugins/soccer-scoreboard/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ def __init__(

# Track active update threads to prevent accumulation of stale threads
self._active_update_threads: Dict[str, threading.Thread] = {} # {name: thread}

# Lock to protect shared mutable state during config reload
self._config_lock = threading.Lock()

# Enable high-FPS mode for scroll display (allows 100+ FPS scrolling)
# This signals to the display controller to use high-FPS loop (8ms = 125 FPS)
Expand Down Expand Up @@ -1126,10 +1129,13 @@ def _get_available_modes(self) -> list:

def _get_current_manager(self):
"""Get the current manager based on the current mode."""
if not self.modes:
with self._config_lock:
modes = self.modes
mode_index = self.current_mode_index
if not modes:
return None

current_mode = self.modes[self.current_mode_index]
current_mode = modes[mode_index % len(modes)]

# Parse mode: soccer_{league_key}_{mode_type}
# Strip "soccer_" prefix and split from right to handle league codes with underscores
Expand All @@ -1145,15 +1151,69 @@ def _get_current_manager(self):

return self._get_league_manager_for_mode(league_key, mode_type)

def on_config_change(self, new_config: Dict[str, Any]) -> None:
"""Apply config changes at runtime without restart."""
if BasePlugin:
super().on_config_change(new_config)
else:
self.config = new_config or {}

self.is_enabled = self.config.get("enabled", True)

# Re-read league enabled states and live priority
leagues_config = self.config.get('leagues', {})
self.league_enabled = {}
self.league_live_priority = {}
for league_key in LEAGUE_KEYS:
league_config = leagues_config.get(league_key, {})
self.league_enabled[league_key] = league_config.get('enabled', False)
self.league_live_priority[league_key] = league_config.get("live_priority", False)

# Re-read global settings
self.display_duration = float(self.config.get("display_duration", 30))
self.game_display_duration = float(self.config.get("game_display_duration", 15))

# Acquire exclusive lock so display()/update() see consistent state
with self._config_lock:
# Drain in-flight update threads before replacing managers
for name, thread in list(self._active_update_threads.items()):
if thread.is_alive():
thread.join(timeout=10.0)
self._active_update_threads.clear()

# Clear stale runtime caches before rebuilding
self._league_registry.clear()
self._scroll_prepared.clear()
self._scroll_active.clear()

# Reinitialize managers and modes
self._initialize_managers()
self._load_custom_leagues()
self._initialize_league_registry()
self._display_mode_settings = self._parse_display_mode_settings()
self.modes = self._get_available_modes()
self.current_mode_index = 0

enabled_leagues = [k for k, v in self.league_enabled.items() if v]
self.logger.info(
f"Config updated at runtime - reinitialized. Enabled leagues: "
f"{', '.join([LEAGUE_NAMES.get(k, k) for k in enabled_leagues]) if enabled_leagues else 'None'}"
)

def update(self) -> None:
"""Update soccer game data using parallel manager updates."""
if not self.is_enabled:
return

# Collect all manager update tasks
# Snapshot registry and thread state under the config lock so we don't
# iterate a dict that on_config_change is rebuilding.
with self._config_lock:
registry_snapshot = dict(self._league_registry)

# Collect all manager update tasks from the snapshot
update_tasks = []

for league_key, league_data in self._league_registry.items():
for league_key, league_data in registry_snapshot.items():
if not league_data.get('enabled', False):
continue

Expand All @@ -1164,44 +1224,45 @@ def update(self) -> None:
manager = managers.get(mode_type)
if manager:
update_tasks.append((f"{league_key}:{league_name} {mode_type.title()}", manager.update))

if not update_tasks:
return

# Run updates in parallel with individual error handling
def run_update_with_error_handling(name: str, update_func):
"""Run a single manager update with error handling."""
try:
update_func()
except Exception as e:
self.logger.error(f"Error updating {name} manager: {e}", exc_info=True)

# Start all update threads, skipping managers with still-running threads
threads = []
started_threads = {} # Track name -> thread for cleanup
for name, update_func in update_tasks:
# Check if a thread for this manager is still running
existing_thread = self._active_update_threads.get(name)
if existing_thread:
if existing_thread.is_alive():
self.logger.debug(
f"Skipping update for {name} - previous thread still running"
)
continue
else:
# Thread completed, remove stale entry
del self._active_update_threads[name]

thread = threading.Thread(
target=run_update_with_error_handling,
args=(name, update_func),
daemon=True,
name=f"Update-{name}"
)
thread.start()
threads.append(thread)
self._active_update_threads[name] = thread
started_threads[name] = thread
with self._config_lock:
for name, update_func in update_tasks:
# Check if a thread for this manager is still running
existing_thread = self._active_update_threads.get(name)
if existing_thread:
if existing_thread.is_alive():
self.logger.debug(
f"Skipping update for {name} - previous thread still running"
)
continue
else:
# Thread completed, remove stale entry
del self._active_update_threads[name]

thread = threading.Thread(
target=run_update_with_error_handling,
args=(name, update_func),
daemon=True,
name=f"Update-{name}"
)
thread.start()
threads.append(thread)
self._active_update_threads[name] = thread
started_threads[name] = thread

# Wait for all threads to complete with a reasonable timeout
for name, thread in started_threads.items():
Expand All @@ -1214,8 +1275,9 @@ def run_update_with_error_handling(name: str, update_func):
# The entry will be removed when the thread eventually completes
else:
# Thread completed successfully, remove from tracking
if name in self._active_update_threads:
del self._active_update_threads[name]
with self._config_lock:
if name in self._active_update_threads:
del self._active_update_threads[name]

def _display_scroll_mode(self, display_mode: str, mode_type: str, force_clear: bool) -> bool:
"""Handle display for scroll mode.
Expand Down Expand Up @@ -1530,19 +1592,31 @@ def display(self, display_mode: str = None, force_clear: bool = False) -> bool:
# Fall back to internal mode cycling if no display_mode provided
current_time = time.time()

# Snapshot modes under lock to avoid racing with on_config_change
with self._config_lock:
modes = self.modes
mode_index = self.current_mode_index

if not modes:
return False

# Clamp index in case modes list shrunk during a config reload
mode_index = mode_index % len(modes)

# Check if we should stay on live mode
should_stay_on_live = False
if self.has_live_content():
# Get current mode name
current_mode = self.modes[self.current_mode_index] if self.modes else None
current_mode = modes[mode_index]
# If we're on a live mode, stay there
if current_mode and current_mode.endswith('_live'):
should_stay_on_live = True
# If we're not on a live mode but have live content, switch to it
elif not (current_mode and current_mode.endswith('_live')):
# Find the first live mode
for i, mode in enumerate(self.modes):
for i, mode in enumerate(modes):
if mode.endswith('_live'):
mode_index = i
self.current_mode_index = i
force_clear = True
self.last_mode_switch = current_time
Expand All @@ -1551,17 +1625,16 @@ def display(self, display_mode: str = None, force_clear: bool = False) -> bool:

# Handle mode cycling only if not staying on live
if not should_stay_on_live and current_time - self.last_mode_switch >= self.display_duration:
self.current_mode_index = (self.current_mode_index + 1) % len(
self.modes
)
mode_index = (mode_index + 1) % len(modes)
self.current_mode_index = mode_index
self.last_mode_switch = current_time
force_clear = True

current_mode = self.modes[self.current_mode_index]
current_mode = modes[mode_index]
self.logger.info(f"Switching to display mode: {current_mode}")

# Get current mode and check if it uses scroll mode
current_mode = self.modes[self.current_mode_index] if self.modes else None
current_mode = modes[mode_index]
if current_mode:
# Extract mode type from current_mode (e.g., "soccer_eng.1_live" -> "live")
# Use rsplit to handle custom league codes with underscores
Expand Down Expand Up @@ -1608,17 +1681,21 @@ def has_live_priority(self) -> bool:
"""Check if any league has live priority enabled."""
if not self.is_enabled:
return False
with self._config_lock:
registry = dict(self._league_registry)
return any(
league_data.get('enabled', False) and league_data.get('live_priority', False)
for _lk, league_data in self._league_registry.items()
for _lk, league_data in registry.items()
)

def has_live_content(self) -> bool:
"""Check if any league has live content."""
if not self.is_enabled:
return False

for league_key, league_data in self._league_registry.items():
with self._config_lock:
registry = dict(self._league_registry)
for league_key, league_data in registry.items():
if not league_data.get('enabled', False):
continue

Expand Down Expand Up @@ -1671,11 +1748,15 @@ def get_info(self) -> Dict[str, Any]:
"""Get plugin information."""
try:
current_manager = self._get_current_manager()
current_mode = self.modes[self.current_mode_index] if self.modes else "none"
with self._config_lock:
modes = self.modes
mode_index = self.current_mode_index
registry_snapshot = dict(self._league_registry)
current_mode = modes[mode_index % len(modes)] if modes else "none"

# Build league info from registry (includes custom leagues)
league_info = {}
for league_key, league_data in self._league_registry.items():
for league_key, league_data in registry_snapshot.items():
league_info[league_key] = {
"enabled": league_data.get("enabled", False),
"live_priority": league_data.get("live_priority", False),
Expand All @@ -1689,7 +1770,7 @@ def get_info(self) -> Dict[str, Any]:
"display_size": f"{self.display_width}x{self.display_height}",
"leagues": league_info,
"current_mode": current_mode,
"available_modes": self.modes,
"available_modes": modes,
"display_duration": self.display_duration,
"game_display_duration": self.game_display_duration,
"show_records": self.config.get("show_records", False),
Expand Down Expand Up @@ -1984,11 +2065,14 @@ def _get_manager_for_mode(self, mode_name: str):

def _record_dynamic_progress(self, current_manager) -> None:
"""Track progress through managers/games for dynamic duration."""
if not self._dynamic_feature_enabled() or not self.modes:
with self._config_lock:
modes = self.modes
mode_index = self.current_mode_index
if not self._dynamic_feature_enabled() or not modes:
self._dynamic_cycle_complete = True
return

current_mode = self.modes[self.current_mode_index]
current_mode = modes[mode_index % len(modes)]
self._dynamic_cycle_seen_modes.add(current_mode)

manager_key = self._build_manager_key(current_mode, current_manager)
Expand Down Expand Up @@ -2022,11 +2106,13 @@ def _evaluate_dynamic_cycle_completion(self) -> None:
self._dynamic_cycle_complete = True
return

if not self.modes:
with self._config_lock:
modes = self.modes
if not modes:
self._dynamic_cycle_complete = True
return

required_modes = [mode for mode in self.modes if mode]
required_modes = [mode for mode in modes if mode]
if not required_modes:
self._dynamic_cycle_complete = True
return
Expand Down
7 changes: 6 additions & 1 deletion plugins/soccer-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "soccer-scoreboard",
"name": "Soccer Scoreboard",
"version": "1.4.4",
"version": "1.5.0",
"author": "ChuckBuilds",
"description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, and more",
"category": "sports",
Expand All @@ -24,6 +24,11 @@
"soccer_upcoming"
],
"versions": [
{
"released": "2026-03-27",
"version": "1.5.0",
"ledmatrix_min": "2.0.0"
},
{
"released": "2026-03-02",
"version": "1.4.4",
Expand Down