feat(schedule): per-plugin schedules and hide-while-live (#799)

* feat(schedule): per-plugin schedules and hide-while-live

Users asked to turn off news and stocks on gameday so only scores show.
Each plugin can now have its own schedule (global or per-day window in
which it may show) plus hide_when_live, which hides it whenever any
plugin reports a live game. A hidden plugin is skipped in the rotation,
the live takeover and the Vegas ticker; it stays loaded, and on-demand
still shows it.

- src/plugin_schedule.py: the window rule, now shared with the display
  schedule (_check_schedule calls schedule_state)
- core config key plugin_schedules; GET/POST /api/v3/config/plugin-schedules
- Schedule page: Plugin Schedules card, one picker per enabled plugin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): tolerate wrong-shaped schedules, quiet skipped modes

- schedule_state treats a non-string mode, non-dict days or a non-dict
  day entry as missing instead of raising in the display loop
- the run loop no longer warns "No plugin manager found" for a mode it
  skips: _plugin_to_draw / _plugin_for_mode already log why (a
  scheduled-off plugin at DEBUG)
- Schedule page DOM test: every form is marked data-reports-result, not
  exactly two (each plugin adds one)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): guard plugin_schedules shape and the cross-thread cache

- a plugin_schedules that is not a dict (list, string) hides nothing
  instead of raising in the display loop
- the hidden-set cache keys on the schedules themselves and is published
  as one tuple, so a Vegas-thread call still holding the pre-reload
  config cannot leave its answer standing after a reload; the separate
  invalidation in _refresh_config_cache is gone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): no live hold on a scheduled-off mode; blank an empty rotation

- _still_live ignores a plugin its schedule hides, so a rotation landing
  on a scheduled-off live mode advances instead of holding a mode it skips
- once a whole rotation has had nothing to show (every plugin empty or
  scheduled off), the panel is cleared once instead of leaving the last
  screen up

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): push the empty-rotation blank to the web preview

clear() alone blanked the matrix but left the preview snapshot on the
last frame (seen on ledpi with every plugin scheduled off). Follow it
with update_display(), as the scheduled-off blank does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): write the empty-rotation blank to the preview

The preview throttle skipped the blank frame (written 12 s after the
last snapshot), and write_owed_snapshot() only runs after a display()
call, which an all-empty rotation never makes: on ledpi the preview
kept the startup splash while the panel was dark. Flush the owed
snapshot on each paused empty pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): quiet the once-a-second logs when nothing can show

With every plugin scheduled off (seen on ledpi), the loop retries once
a second and journaled three to four lines each time: Vegas's
"No plugins available" WARNING, the coordinator's "Failed to initialize
stream manager" ERROR, the 0-plugin rotation summary and "Switching to
mode". Vegas now warns once until its list fills again, the coordinator
line is DEBUG (initialize only fails for an empty list), and the
mode-switch line is DEBUG once a whole rotation has been empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schedule): end the scroll before the empty blank; Vegas sees hidden changes sooner

- the empty-rotation blank is controller-drawn, so it ends the last
  scroll's state first (_end_scroll_before_core_screen), as the
  scheduled-off blank does
- StreamManager.refresh() rebuilds the plugin list when the hidden set
  changed, even inside its 30 s interval, so a newly hidden plugin leaves
  at the next scroll group rather than staying for later ones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-18 16:16:47 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7c51c93707
commit 1fe7965f5c
19 changed files with 816 additions and 221 deletions
+163 -108
View File
@@ -224,6 +224,105 @@ def get_refresh_rate():
}})
def _build_schedule(data):
"""``(schedule, None)`` from a schedule picker's POST body, or ``(None, message)``.
The display schedule and each plugin schedule (``plugin_schedules``)
share it. Times are validated; a per-day schedule that is on needs a day
that is on.
"""
# Build schedule configuration
# Handle enabled checkbox - can be True, False, or 'on'
enabled_value = data.get('enabled', False)
if isinstance(enabled_value, str):
enabled_value = enabled_value.lower() in ('true', 'on', '1')
schedule_config = {
'enabled': enabled_value
}
mode = data.get('mode', 'global')
schedule_config['mode'] = mode
if mode == 'global':
# Simple global schedule
start_time = data.get('start_time', '07:00')
end_time = data.get('end_time', '23:00')
# Validate time formats
is_valid, error_msg = _validate_time_format(start_time)
if not is_valid:
return None, error_msg
is_valid, error_msg = _validate_time_format(end_time)
if not is_valid:
return None, error_msg
schedule_config['start_time'] = start_time
schedule_config['end_time'] = end_time
# Remove days config when switching to global mode
schedule_config.pop('days', None)
else:
# Per-day schedule
schedule_config['days'] = {}
# Remove global times when switching to per-day mode
schedule_config.pop('start_time', None)
schedule_config.pop('end_time', None)
days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
enabled_days_count = 0
for day in days:
day_config = {}
enabled_key = f'{day}_enabled'
start_key = f'{day}_start'
end_key = f'{day}_end'
# Check if day is enabled
has_enabled, enabled_val = _day_setting(data, day, enabled_key, 'enabled')
if has_enabled:
# Handle checkbox values that may come as 'on', True, or False
if isinstance(enabled_val, str):
day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1')
else:
day_config['enabled'] = bool(enabled_val)
else:
# Default to enabled if not specified
day_config['enabled'] = True
# Only add times if day is enabled
if day_config.get('enabled', True):
enabled_days_count += 1
start_time = None
end_time = None
start_time = _day_setting(data, day, start_key, 'start_time')[1] or '07:00'
end_time = _day_setting(data, day, end_key, 'end_time')[1] or '23:00'
# Validate time formats
is_valid, error_msg = _validate_time_format(start_time)
if not is_valid:
return None, f"Invalid start time for {day}: {error_msg}"
is_valid, error_msg = _validate_time_format(end_time)
if not is_valid:
return None, f"Invalid end time for {day}: {error_msg}"
day_config['start_time'] = start_time
day_config['end_time'] = end_time
else:
day_config.update(_disabled_day_times(data, day, start_key, end_key))
schedule_config['days'][day] = day_config
# An enabled per-day schedule needs a day to be on. A disabled
# one does not: every day off with the schedule off is what
# config.template.json ships, so refusing it meant a fresh
# install could not post back the schedule GET returned.
if enabled_days_count == 0 and enabled_value:
return None, "At least one day must be enabled in per-day schedule mode"
return schedule_config, None
@api_v3.route('/config/schedule', methods=['GET'])
def get_schedule_config():
"""Get current schedule configuration"""
@@ -261,114 +360,9 @@ def save_schedule_config():
# Load current config
current_config = api_v3.config_manager.load_config()
# Build schedule configuration
# Handle enabled checkbox - can be True, False, or 'on'
enabled_value = data.get('enabled', False)
if isinstance(enabled_value, str):
enabled_value = enabled_value.lower() in ('true', 'on', '1')
schedule_config = {
'enabled': enabled_value
}
mode = data.get('mode', 'global')
schedule_config['mode'] = mode
if mode == 'global':
# Simple global schedule
start_time = data.get('start_time', '07:00')
end_time = data.get('end_time', '23:00')
# Validate time formats
is_valid, error_msg = _validate_time_format(start_time)
if not is_valid:
return error_response(
ErrorCode.VALIDATION_ERROR,
error_msg,
status_code=400
)
is_valid, error_msg = _validate_time_format(end_time)
if not is_valid:
return error_response(
ErrorCode.VALIDATION_ERROR,
error_msg,
status_code=400
)
schedule_config['start_time'] = start_time
schedule_config['end_time'] = end_time
# Remove days config when switching to global mode
schedule_config.pop('days', None)
else:
# Per-day schedule
schedule_config['days'] = {}
# Remove global times when switching to per-day mode
schedule_config.pop('start_time', None)
schedule_config.pop('end_time', None)
days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
enabled_days_count = 0
for day in days:
day_config = {}
enabled_key = f'{day}_enabled'
start_key = f'{day}_start'
end_key = f'{day}_end'
# Check if day is enabled
has_enabled, enabled_val = _day_setting(data, day, enabled_key, 'enabled')
if has_enabled:
# Handle checkbox values that may come as 'on', True, or False
if isinstance(enabled_val, str):
day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1')
else:
day_config['enabled'] = bool(enabled_val)
else:
# Default to enabled if not specified
day_config['enabled'] = True
# Only add times if day is enabled
if day_config.get('enabled', True):
enabled_days_count += 1
start_time = None
end_time = None
start_time = _day_setting(data, day, start_key, 'start_time')[1] or '07:00'
end_time = _day_setting(data, day, end_key, 'end_time')[1] or '23:00'
# Validate time formats
is_valid, error_msg = _validate_time_format(start_time)
if not is_valid:
return error_response(
ErrorCode.VALIDATION_ERROR,
f"Invalid start time for {day}: {error_msg}",
status_code=400
)
is_valid, error_msg = _validate_time_format(end_time)
if not is_valid:
return error_response(
ErrorCode.VALIDATION_ERROR,
f"Invalid end time for {day}: {error_msg}",
status_code=400
)
day_config['start_time'] = start_time
day_config['end_time'] = end_time
else:
day_config.update(_disabled_day_times(data, day, start_key, end_key))
schedule_config['days'][day] = day_config
# An enabled per-day schedule needs a day to be on. A disabled
# one does not: every day off with the schedule off is what
# config.template.json ships, so refusing it meant a fresh
# install could not post back the schedule GET returned.
if enabled_days_count == 0 and enabled_value:
return error_response(
ErrorCode.VALIDATION_ERROR,
"At least one day must be enabled in per-day schedule mode",
status_code=400
)
schedule_config, error_msg = _build_schedule(data)
if error_msg:
return error_response(ErrorCode.VALIDATION_ERROR, error_msg, status_code=400)
# Update and save config using atomic save
current_config['schedule'] = schedule_config
@@ -391,6 +385,67 @@ def save_schedule_config():
status_code=500, details=describe_exception(e)
)
@api_v3.route('/config/plugin-schedules', methods=['GET'])
def get_plugin_schedules():
"""Every plugin's schedule (config['plugin_schedules']), by plugin id."""
if not api_v3.config_manager:
return error_response(ErrorCode.CONFIG_LOAD_FAILED,
'Config manager not initialized', status_code=500)
config = api_v3.config_manager.load_config()
return success_response(data=config.get('plugin_schedules') or {})
@api_v3.route('/config/plugin-schedules', methods=['POST'])
def save_plugin_schedule():
"""Save one plugin's schedule (src/plugin_schedule.py).
The body is a schedule picker's fields plus ``plugin_id`` and
``hide_when_live``. A schedule that is off and does not hide during live
games changes nothing, so it is removed rather than stored.
"""
try:
if not api_v3.config_manager:
return error_response(ErrorCode.CONFIG_LOAD_FAILED,
'Config manager not initialized', status_code=500)
data = request.get_json(silent=True)
if not isinstance(data, dict):
return error_response(ErrorCode.VALIDATION_ERROR, 'No data provided', status_code=400)
plugin_id = data.get('plugin_id')
if not isinstance(plugin_id, str) or not plugin_id:
return error_response(ErrorCode.VALIDATION_ERROR, 'plugin_id is required', status_code=400)
not_plugin = _pkg._non_plugin_id_error(plugin_id)
if not_plugin is not None:
return not_plugin
if plugin_id not in _pkg._discovered_plugin_manifests(plugin_id):
return error_response(ErrorCode.PLUGIN_NOT_FOUND,
f"Plugin '{plugin_id}' is not installed", status_code=404)
schedule, error_msg = _build_schedule(data)
if error_msg:
return error_response(ErrorCode.VALIDATION_ERROR, error_msg, status_code=400)
schedule['hide_when_live'] = _coerce_to_bool(data.get('hide_when_live'))
current_config = api_v3.config_manager.load_config()
schedules = dict(current_config.get('plugin_schedules') or {})
if schedule['enabled'] or schedule['hide_when_live']:
schedules[plugin_id] = schedule
else:
schedules.pop(plugin_id, None)
current_config['plugin_schedules'] = schedules
success, error_msg = _pkg._save_config_atomic(api_v3.config_manager, current_config,
create_backup=True)
if not success:
return error_response(ErrorCode.CONFIG_SAVE_FAILED,
f"Failed to save plugin schedule: {error_msg}", status_code=500)
invalidate_cache()
return success_response(message=f"Schedule for {plugin_id} saved")
except Exception as e:
logger.error("Error saving plugin schedule", exc_info=True)
return error_response(ErrorCode.CONFIG_SAVE_FAILED,
"An error occurred; see logs for details",
status_code=500, details=describe_exception(e))
@api_v3.route('/config/dim-schedule', methods=['GET'])
def get_dim_schedule_config():
"""Get current dim schedule configuration"""