Feature/music skip delay (#115)

* feat: Add configurable delay for music module skip when nothing playing

- Add 'skip_when_nothing_playing' and 'skip_delay_seconds' config options
- Implement timer-based skip logic that waits 2 seconds before skipping
- Reset timer when music resumes or display is deactivated
- Improve user experience by preventing jarring immediate skips
- Maintains backward compatibility with existing configurations

* feat: Add live priority support to music manager

- Add 'live_priority' and 'live_game_duration' config options for music
- Integrate music manager into live priority rotation system
- Add 'music_live' mode that participates in live priority takeover
- Music manager joins live priority rotation when actively playing
- Supports future plugin ecosystem with consistent live priority interface
- Maintains backward compatibility with existing music configurations

* feat: Add new music settings to web interface

- Add 'Skip When Nothing Playing' checkbox and delay configuration
- Add 'Enable Live Priority' checkbox and duration configuration
- Update music form JavaScript to save all new settings
- Users can now configure music skip behavior and live priority through web UI

* fix: Improve music live priority integration

- Enhanced music live content detection with detailed logging
- Modified live priority logic to properly handle music mode
- Added music to live priority sports collection when actively playing
- Updated live priority rotation to handle music mode correctly
- Improved live priority takeover logic for music manager
- Music now properly participates in live priority rotation when playing

* perf: Reduce logging frequency for music live priority

- Add timestamp-based throttling to music live priority logging
- Info messages now only appear every 30 seconds instead of every few milliseconds
- Debug messages are also throttled to reduce log spam
- Maintains visibility of important events while reducing noise

* Fix unreachable code bug in music live priority handling

- Moved music live priority logic outside the sports iteration loop where it was unreachable
- The 'if sport == music' block was never executed since 'music' wasn't in the sports list
- Consolidated music handling in its own separate section for cleaner code structure
- Added music configuration options to config template
- Maintained exact same functionality while eliminating dead code

* Fix music_live_game_duration setting not being used

- Modified get_current_duration() method to check for music live priority mode
- When music is in live priority mode, use configured live_game_duration setting
- Added proper config reading for music_live_game_duration during initialization
- Ensures music displays for the configured duration when in live priority mode

* Fix stale timestamp bug in live priority debug logging

- Fixed issue where debug log at line 1240 was using a stale current_time variable
- The current_time variable was conditionally reassigned in music live priority check
- This caused inconsistent log throttling behavior
- Now uses fresh current_time_for_debug variable for accurate throttling
This commit is contained in:
Chuck
2025-10-16 16:46:37 -04:00
committed by GitHub
parent 9c313cdac3
commit ce79ccaec2
4 changed files with 223 additions and 40 deletions

View File

@@ -577,7 +577,11 @@
"enabled": false,
"preferred_source": "ytm",
"YTM_COMPANION_URL": "http://192.168.86.12:9863",
"POLLING_INTERVAL_SECONDS": 1
"POLLING_INTERVAL_SECONDS": 1,
"skip_when_nothing_playing": true,
"skip_delay_seconds": 2,
"live_priority": true,
"live_game_duration": 30
},
"of_the_day": {
"enabled": false,

View File

@@ -38,7 +38,7 @@ from src.youtube_display import YouTubeDisplay
from src.calendar_manager import CalendarManager
from src.text_display import TextDisplay
from src.static_image_manager import StaticImageManager
from src.music_manager import MusicManager
from src.music_manager import MusicManager, SkipModuleException
from src.of_the_day_manager import OfTheDayManager
from src.news_manager import NewsManager
@@ -103,8 +103,11 @@ class DisplayController:
self.music_manager = None
else:
logger.info("Music module is disabled in main configuration (config.json).")
# Read music live game duration setting regardless of whether music is enabled
self.music_live_game_duration = music_config_main.get('live_game_duration', 30)
else:
logger.error("Config not loaded before MusicManager initialization attempt.")
self.music_live_game_duration = 30 # Default fallback
logger.info("MusicManager initialized in %.3f seconds", time.time() - music_init_time)
# Initialize NHL managers if enabled
@@ -324,6 +327,12 @@ class DisplayController:
self.ncaaw_basketball_live_priority = self.config.get('ncaaw_basketball_scoreboard', {}).get('live_priority', True)
self.ncaam_hockey_live_priority = self.config.get('ncaam_hockey_scoreboard', {}).get('live_priority', True)
self.ncaaw_hockey_live_priority = self.config.get('ncaaw_hockey_scoreboard', {}).get('live_priority', True)
self.music_live_priority = self.config.get('music', {}).get('live_priority', True)
# Live priority logging throttling
self._last_music_live_priority_log = 0
self._last_music_rotation_log = 0
self._music_live_priority_log_interval = 30.0 # Log every 30 seconds
# List of available display modes (adjust order as desired)
self.available_modes = []
@@ -493,6 +502,7 @@ class DisplayController:
'ncaa_fb_recent': 15,
'ncaa_fb_upcoming': 15,
'music': 20, # Default duration for music, will be overridden by config if present
'music_live': 30, # Default duration for music live mode
'ncaa_baseball_live': 30, # Added NCAA Baseball durations
'ncaa_baseball_recent': 15,
'ncaa_baseball_upcoming': 15,
@@ -644,6 +654,16 @@ class DisplayController:
# Fall back to configured duration
return self.display_durations.get(mode_key, 600)
# Handle music live priority mode
elif mode_key == 'music' and self.music_live_priority and self.music_manager and self._music_has_live_content():
# Use the configured live game duration for music when in live priority mode
music_live_duration = getattr(self, 'music_live_game_duration', 30)
# Only log if duration has changed or we haven't logged this duration yet
if not hasattr(self, '_last_logged_music_live_duration') or self._last_logged_music_live_duration != music_live_duration:
logger.info(f"Using music live priority duration: {music_live_duration} seconds")
self._last_logged_music_live_duration = music_live_duration
return music_live_duration
# Simplify weather key handling
elif mode_key.startswith('weather_'):
return self.display_durations.get(mode_key, 15)
@@ -1034,12 +1054,34 @@ class DisplayController:
return
if not live_priority:
# Only add to rotation if manager exists and has live games
if manager and getattr(manager, 'live_games', None):
live_games = getattr(manager, 'live_games', None)
if mode_name not in self.available_modes:
self.available_modes.append(mode_name)
logger.debug(f"Added {mode_name} to rotation (found {len(live_games)} live games)")
# Only add to rotation if manager exists and has live games/content
if manager:
# Special handling for music manager - check if music is actively playing
if mode_name == 'music_live':
has_live_content = self._music_has_live_content()
logger.debug(f"Music live mode update: mode_name={mode_name}, has_live_content={has_live_content}, in_available_modes={mode_name in self.available_modes}")
if has_live_content and mode_name not in self.available_modes:
self.available_modes.append(mode_name)
# Throttle logging - only log every 30 seconds
current_time = time.time()
if current_time - self._last_music_rotation_log >= self._music_live_priority_log_interval:
logger.info(f"Added {mode_name} to rotation (music is playing)")
self._last_music_rotation_log = current_time
elif not has_live_content and mode_name in self.available_modes:
self.available_modes.remove(mode_name)
# Throttle logging - only log every 30 seconds
current_time = time.time()
if current_time - self._last_music_rotation_log >= self._music_live_priority_log_interval:
logger.info(f"Removed {mode_name} from rotation (music stopped)")
self._last_music_rotation_log = current_time
elif getattr(manager, 'live_games', None):
live_games = getattr(manager, 'live_games', None)
if mode_name not in self.available_modes:
self.available_modes.append(mode_name)
logger.debug(f"Added {mode_name} to rotation (found {len(live_games)} live games)")
else:
if mode_name in self.available_modes:
self.available_modes.remove(mode_name)
else:
if mode_name in self.available_modes:
self.available_modes.remove(mode_name)
@@ -1077,6 +1119,32 @@ class DisplayController:
update_mode('ncaaw_basketball_live', getattr(self, 'ncaaw_basketball_live', None), self.ncaaw_basketball_live_priority, ncaaw_basketball_enabled)
update_mode('ncaam_hockey_live', getattr(self, 'ncaam_hockey_live', None), self.ncaam_hockey_live_priority, ncaam_hockey_enabled)
update_mode('ncaaw_hockey_live', getattr(self, 'ncaaw_hockey_live', None), self.ncaaw_hockey_live_priority, ncaaw_hockey_enabled)
# Add music to live priority rotation if enabled and music is playing
music_enabled = self.config.get('music', {}).get('enabled', False)
# Throttle debug logging - only log every 30 seconds
current_time = time.time()
if current_time - self._last_music_rotation_log >= self._music_live_priority_log_interval:
logger.debug(f"Music enabled: {music_enabled}, live_priority: {self.music_live_priority}, manager: {self.music_manager is not None}")
update_mode('music_live', self.music_manager, self.music_live_priority, music_enabled)
def _music_has_live_content(self) -> bool:
"""Check if music manager has live content (actively playing music)."""
if not self.music_manager:
logger.debug("Music manager not initialized")
return False
# Get current track info from music manager
current_track_info = self.music_manager.get_current_display_info()
if not current_track_info:
logger.debug("No current track info from music manager")
return False
# Check if music is currently playing
is_playing = current_track_info.get('is_playing', False)
title = current_track_info.get('title', '')
has_live_content = is_playing and title != 'Nothing Playing'
return has_live_content
def run(self):
"""Run the display controller, switching between displays."""
@@ -1114,6 +1182,15 @@ class DisplayController:
has_live_games, live_sport_type = self._check_live_games()
is_currently_live = self.current_display_mode.endswith('_live')
# Special case: if we're in music mode and music is actively playing, treat it as live
if self.current_display_mode == 'music' and self._music_has_live_content():
is_currently_live = True
# Throttle logging - only log every 30 seconds
current_time = time.time()
if current_time - self._last_music_rotation_log >= self._music_live_priority_log_interval:
logger.debug("Music mode with active content treated as live priority")
self._last_music_rotation_log = current_time
# Collect all sports with live_priority=True that have live games
live_priority_sports = []
for sport, attr, priority in [
@@ -1132,7 +1209,8 @@ class DisplayController:
('ncaaw_hockey', 'ncaaw_hockey_live', self.ncaaw_hockey_live_priority)
]:
manager = getattr(self, attr, None)
# Only consider sports that are enabled (manager is not None) and have actual live games
# Standard sports logic
live_games = getattr(manager, 'live_games', None) if manager is not None else None
# Check that manager exists, has live_priority enabled, has live_games attribute, and has at least one live game
if (manager is not None and
@@ -1144,27 +1222,43 @@ class DisplayController:
elif manager is not None and priority and live_games is not None:
logger.debug(f"{sport} has live_priority=True but {len(live_games)} live games (not taking over)")
# Special handling for music - check if music is actively playing
if (self.music_manager is not None and
self.music_live_priority and
self._music_has_live_content()):
live_priority_sports.append('music')
# Throttle logging - only log every 30 seconds
current_time = time.time()
if current_time - self._last_music_live_priority_log >= self._music_live_priority_log_interval:
logger.info("Live priority music found: music is playing - added to live priority rotation")
self._last_music_live_priority_log = current_time
# Determine if we have any live priority sports
live_priority_takeover = len(live_priority_sports) > 0
# Throttle debug logging - only log every 30 seconds
current_time_for_debug = time.time()
if current_time_for_debug - self._last_music_rotation_log >= self._music_live_priority_log_interval:
logger.debug(f"Live priority sports: {live_priority_sports}, takeover: {live_priority_takeover}")
manager_to_display = None
# --- State Machine for Display Logic ---
if is_currently_live:
if live_priority_takeover:
# Check if we need to rotate to the next live priority sport
current_sport_type = self.current_display_mode.replace('_live', '')
current_sport_type = self.current_display_mode.replace('_live', '') if self.current_display_mode.endswith('_live') else self.current_display_mode
# If current sport is not in live priority sports, switch to first one
if current_sport_type not in live_priority_sports:
next_sport = live_priority_sports[0]
new_mode = f"{next_sport}_live"
# Special case for music - use "music" mode instead of "music_live"
new_mode = "music" if next_sport == "music" else f"{next_sport}_live"
logger.info(f"Current live sport {current_sport_type} no longer has priority, switching to {new_mode}")
self.current_display_mode = new_mode
if hasattr(self, '_last_logged_duration'):
delattr(self, '_last_logged_duration')
self.force_clear = True
self.last_switch = current_time
manager_to_display = getattr(self, f"{next_sport}_live", None)
manager_to_display = self.music_manager if next_sport == "music" else getattr(self, f"{next_sport}_live", None)
else:
# Check if duration has elapsed for current sport
current_duration = self.get_current_duration()
@@ -1173,7 +1267,8 @@ class DisplayController:
current_index = live_priority_sports.index(current_sport_type)
next_index = (current_index + 1) % len(live_priority_sports)
next_sport = live_priority_sports[next_index]
new_mode = f"{next_sport}_live"
# Special case for music - use "music" mode instead of "music_live"
new_mode = "music" if next_sport == "music" else f"{next_sport}_live"
logger.info(f"Rotating live priority sports: {current_sport_type} -> {next_sport} (duration: {current_duration}s)")
self.current_display_mode = new_mode
@@ -1181,10 +1276,11 @@ class DisplayController:
delattr(self, '_last_logged_duration')
self.force_clear = True
self.last_switch = current_time
manager_to_display = getattr(self, f"{next_sport}_live", None)
manager_to_display = self.music_manager if next_sport == "music" else getattr(self, f"{next_sport}_live", None)
else:
self.force_clear = False
manager_to_display = getattr(self, f"{current_sport_type}_live", None)
# Special case for music - use "music" mode instead of "music_live"
manager_to_display = self.music_manager if current_sport_type == "music" else getattr(self, f"{current_sport_type}_live", None)
else:
# If no sport has live_priority takeover, treat as regular rotation
is_currently_live = False
@@ -1193,14 +1289,19 @@ class DisplayController:
if live_priority_takeover:
# Switch to first live priority sport
next_sport = live_priority_sports[0]
new_mode = f"{next_sport}_live"
# Special case for music - use "music" mode instead of "music_live"
new_mode = "music" if next_sport == "music" else f"{next_sport}_live"
# Double-check that the manager actually has live games before switching
target_manager = getattr(self, f"{next_sport}_live", None)
if target_manager and hasattr(target_manager, 'live_games') and len(target_manager.live_games) > 0:
# Double-check that the manager actually has live content before switching
if next_sport == "music":
target_manager = self.music_manager if self._music_has_live_content() else None
else:
target_manager = getattr(self, f"{next_sport}_live", None)
if target_manager and ((next_sport == "music") or (hasattr(target_manager, 'live_games') and len(target_manager.live_games) > 0)):
logger.info(f"Live priority takeover: Switching to {new_mode} from {self.current_display_mode}")
logger.debug(f"[DisplayController] Live priority takeover details: sport={next_sport}, manager={target_manager}, live_games={target_manager.live_games}")
if previous_mode_before_switch == 'music' and self.music_manager:
logger.debug(f"[DisplayController] Live priority takeover details: sport={next_sport}, manager={target_manager}")
if previous_mode_before_switch == 'music' and self.music_manager and next_sport != "music":
self.music_manager.deactivate_music_display()
self.current_display_mode = new_mode
# Reset logged duration when mode changes
@@ -1210,7 +1311,7 @@ class DisplayController:
self.last_switch = current_time
manager_to_display = target_manager
else:
logger.warning(f"[DisplayController] Live priority takeover attempted for {new_mode} but manager has no live games, skipping takeover")
logger.warning(f"[DisplayController] Live priority takeover attempted for {new_mode} but manager has no live content, skipping takeover")
live_priority_takeover = False
else:
# No live_priority takeover, regular rotation
@@ -1329,6 +1430,8 @@ class DisplayController:
manager_to_display = self.soccer_upcoming
elif self.current_display_mode == 'music' and self.music_manager:
manager_to_display = self.music_manager
elif self.current_display_mode == 'music_live' and self.music_manager:
manager_to_display = self.music_manager
elif self.current_display_mode == 'nhl_live' and self.nhl_live:
manager_to_display = self.nhl_live
elif self.current_display_mode == 'nba_live' and self.nba_live:
@@ -1377,12 +1480,22 @@ class DisplayController:
logger.info(f"manager_to_display is {current_manager_type}")
self._last_logged_manager_type = current_manager_type
if self.current_display_mode == 'music' and self.music_manager:
if self.current_display_mode in ['music', 'music_live'] and self.music_manager:
# Call MusicManager's display method
self.music_manager.display(force_clear=self.force_clear)
# Reset force_clear if it was true for this mode
if self.force_clear:
self.force_clear = False
try:
self.music_manager.display(force_clear=self.force_clear)
# Reset force_clear if it was true for this mode
if self.force_clear:
self.force_clear = False
except SkipModuleException as e:
# Music module requested skip (nothing playing and configured to skip)
logger.info(f"Music module requested skip: {e}")
# Force change to next module
self.force_change = True
except Exception as e:
logger.error(f"Unexpected error in music display: {e}", exc_info=True)
# Reset force_clear on error
self.force_clear = True
elif manager_to_display:
logger.debug(f"Attempting to display mode: {self.current_display_mode} using manager {type(manager_to_display).__name__} with force_clear={self.force_clear}")
# Call the appropriate display method based on mode/manager type

View File

@@ -34,6 +34,10 @@ class MusicSource(Enum):
SPOTIFY = auto()
YTM = auto()
class SkipModuleException(StopIteration):
"""Exception raised when a module should be skipped to the next one."""
pass
class MusicManager:
def __init__(self, display_manager, config, update_callback=None):
self.display_manager = display_manager
@@ -65,6 +69,7 @@ class MusicManager:
self.album_scroll_tick = 0
self.is_music_display_active = False # New state variable
self.is_currently_showing_nothing_playing = False # To prevent flashing
self.nothing_playing_start_time = None # Timestamp when "Nothing Playing" started being displayed
self._needs_immediate_full_refresh = False # Flag for forcing refresh from YTM updates
self.ytm_event_data_queue = queue.Queue(maxsize=1) # Queue for event data
@@ -306,6 +311,10 @@ class MusicManager:
def deactivate_music_display(self):
logger.info("Music display deactivated.")
self.is_music_display_active = False
# Reset the nothing playing timer when display is deactivated
if self.nothing_playing_start_time is not None:
logger.debug("MusicManager: Display deactivated, resetting nothing playing timer")
self.nothing_playing_start_time = None
if self.ytm and self.ytm.is_connected:
logger.info("Disconnecting YTM client due to music display deactivation.")
self.ytm.disconnect_client()
@@ -692,6 +701,23 @@ class MusicManager:
if perform_full_refresh_this_cycle:
logger.debug(f"MusicManager.display (Full Refresh Render): Using snapshot - Title: '{snapshot_title_for_log}'")
# --- Check if we should skip when nothing is playing ---
music_config = self.config.get('music', {})
skip_when_nothing_playing = music_config.get('skip_when_nothing_playing', True)
skip_delay_seconds = music_config.get('skip_delay_seconds', 2)
if (not current_track_info_snapshot or current_track_info_snapshot.get('title') == 'Nothing Playing') and skip_when_nothing_playing:
# Check if we've been showing "Nothing Playing" for the configured delay
current_time = time.time()
if self.nothing_playing_start_time is None:
# We just started showing "Nothing Playing", set the start time
self.nothing_playing_start_time = current_time
logger.debug(f"MusicManager: Started showing 'Nothing Playing', timer set (will skip after {skip_delay_seconds}s)")
elif current_time - self.nothing_playing_start_time >= skip_delay_seconds:
logger.info(f"MusicManager: Nothing playing for {skip_delay_seconds}+ seconds, raising SkipModuleException to advance to next module.")
raise SkipModuleException(f"Music module: Nothing playing for {skip_delay_seconds}+ seconds and skip_when_nothing_playing is enabled")
# --- Original Nothing Playing Logic ---
if not current_track_info_snapshot or current_track_info_snapshot.get('title') == 'Nothing Playing':
if not hasattr(self, '_last_nothing_playing_log_time') or time.time() - getattr(self, '_last_nothing_playing_log_time', 0) > 30:
@@ -722,6 +748,11 @@ class MusicManager:
self.last_album_art_url = None
return
# Reset the nothing playing timer when music starts playing
if self.nothing_playing_start_time is not None:
logger.debug("MusicManager: Music started playing, resetting nothing playing timer")
self.nothing_playing_start_time = None
self.is_currently_showing_nothing_playing = False
if perform_full_refresh_this_cycle:

View File

@@ -1790,6 +1790,37 @@
<input type="number" class="form-control" id="music_polling_interval" name="music_polling_interval" value="{{ safe_config_get(main_config, 'music', 'POLLING_INTERVAL_SECONDS', default=1) }}" min="1" max="60">
<div class="description">How often to check for music updates</div>
</div>
<div class="form-row">
<div class="form-group">
<label>
<input type="checkbox" id="music_skip_when_nothing_playing" name="music_skip_when_nothing_playing" {% if safe_config_get(main_config, 'music', 'skip_when_nothing_playing', default=True) %}checked{% endif %}>
Skip When Nothing Playing
</label>
<div class="description">Skip to next module when no music is playing</div>
</div>
<div class="form-group">
<label for="music_skip_delay_seconds">Skip Delay (seconds):</label>
<input type="number" class="form-control" id="music_skip_delay_seconds" name="music_skip_delay_seconds" value="{{ safe_config_get(main_config, 'music', 'skip_delay_seconds', default=2) }}" min="0" max="60">
<div class="description">Wait time before skipping when nothing playing</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>
<input type="checkbox" id="music_live_priority" name="music_live_priority" {% if safe_config_get(main_config, 'music', 'live_priority', default=True) %}checked{% endif %}>
Enable Live Priority
</label>
<div class="description">Include music in live priority rotation when playing</div>
</div>
<div class="form-group">
<label for="music_live_game_duration">Live Duration (seconds):</label>
<input type="number" class="form-control" id="music_live_game_duration" name="music_live_game_duration" value="{{ safe_config_get(main_config, 'music', 'live_game_duration', default=30) }}" min="10" max="300">
<div class="description">How long music stays in live priority rotation</div>
</div>
</div>
<button type="submit" class="btn btn-success">Save Music Settings</button>
</form>
</div>
@@ -3222,7 +3253,11 @@
enabled: document.getElementById('music_enabled').checked,
preferred_source: document.getElementById('music_preferred_source').value,
YTM_COMPANION_URL: document.getElementById('ytm_companion_url').value,
POLLING_INTERVAL_SECONDS: parseInt(document.getElementById('music_polling_interval').value)
POLLING_INTERVAL_SECONDS: parseInt(document.getElementById('music_polling_interval').value),
skip_when_nothing_playing: document.getElementById('music_skip_when_nothing_playing').checked,
skip_delay_seconds: parseInt(document.getElementById('music_skip_delay_seconds').value),
live_priority: document.getElementById('music_live_priority').checked,
live_game_duration: parseInt(document.getElementById('music_live_game_duration').value)
}
};
await saveConfigJson(payload);