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>
This commit is contained in:
Chuck
2026-10-17 19:29:29 -04:00
co-authored by Claude Opus 5.5
parent 7c51c93707
commit 1c733352b5
15 changed files with 693 additions and 210 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"""
+22 -1
View File
@@ -615,7 +615,28 @@ def _load_schedule_partial():
return render_template('v3/partials/schedule.html',
schedule_config=schedule_config,
dim_schedule_config=dim_schedule_config,
normal_brightness=normal_brightness)
normal_brightness=normal_brightness,
plugin_schedule_rows=_plugin_schedule_rows(main_config))
def _plugin_schedule_rows(main_config):
"""One row per enabled plugin, or plugin with a saved schedule, for the
Plugin Schedules card: ``{id, name, schedule}``, sorted by name."""
schedules = main_config.get('plugin_schedules') or {}
if not pages_v3.plugin_catalog:
return []
try:
pages_v3.plugin_catalog.discover_plugins()
infos = pages_v3.plugin_catalog.get_all_plugin_info()
except Exception:
logger.warning("schedule: could not enumerate plugins", exc_info=True)
return []
rows = [{'id': info['id'], 'name': info.get('name') or info['id'],
'schedule': schedules.get(info['id']) or {}}
for info in infos
if info.get('id') and ((main_config.get(info['id']) or {}).get('enabled', False)
or info['id'] in schedules)]
return sorted(rows, key=lambda r: r['name'].lower())
def _load_plugins_partial():
+20 -1
View File
@@ -39,12 +39,26 @@ const PICKERS = [
},
];
// Each plugin's picker (the Plugin Schedules card): the window in which the
// plugin may show. Unset, it offers the whole day.
const PLUGIN_PICKER = { label: 'PluginSchedule', start: '00:00', end: '23:59', fillEmptyDays: true };
const FORMS = {
schedule_form: { saved: 'Schedule settings saved', failed: 'Error saving schedule' },
dim_schedule_form: { saved: 'Dim schedule settings saved', failed: 'Error saving dim schedule' },
};
const PLUGIN_FORM = { saved: 'Plugin schedule saved', failed: 'Error saving plugin schedule' };
/** The notification labels for a form on this page, or null. */
function formLabels(form) {
if (FORMS[form.id]) return FORMS[form.id];
return form.classList.contains('plugin-schedule-form') ? PLUGIN_FORM : null;
}
const WIDGET_OPTIONS = { showModeToggle: true, showEnableToggle: true, compactMode: false };
const PLUGIN_WIDGET_OPTIONS = Object.assign({}, WIDGET_OPTIONS, {
enableDescription: 'When enabled, this plugin only shows during the hours below.',
});
// The mounted page, for the deprecated aliases (boot.js).
let active = null;
@@ -126,6 +140,11 @@ function drawPickers(root, ctx) {
widget.render(container, { 'x-options': WIDGET_OPTIONS },
widgetValue(readConfig(root, spec.data), spec), { fieldId: spec.fieldId });
});
root.querySelectorAll('[data-plugin-schedule-config]').forEach(function(container) {
widget.render(container, { 'x-options': PLUGIN_WIDGET_OPTIONS },
widgetValue(readConfig(container, 'data-plugin-schedule-config'), PLUGIN_PICKER),
{ fieldId: container.id });
});
}
attempt();
}
@@ -135,7 +154,7 @@ export function init(root, ctx) {
root.addEventListener('htmx:afterRequest', function(event) {
const form = event.target && event.target.closest ? event.target.closest('form') : null;
const labels = form && root.contains(form) ? FORMS[form.id] : null;
const labels = form && root.contains(form) ? formLabels(form) : null;
if (labels && event.detail && event.detail.xhr) report(ctx.notify, event.detail.xhr, labels);
}, on);
@@ -188,7 +188,7 @@
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Enable Schedule</span>
</label>
<p class="mt-1 text-sm text-gray-600">When enabled, the display will only operate during specified hours.</p>
<p class="mt-1 text-sm text-gray-600">${window.LEDEscape.html(xOptions.enableDescription || 'When enabled, the display will only operate during specified hours.')}</p>
</div>
`;
}
@@ -85,4 +85,51 @@
</div>
</form>
</div>
<!-- Plugin Schedules Section (config plugin_schedules, src/plugin_schedule.py) -->
<div class="bg-white rounded-lg shadow p-6 mt-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Plugin Schedules</h2>
<p class="mt-1 text-sm text-gray-600">Choose when each plugin may show. Outside its schedule a plugin is skipped in the rotation and in Vegas mode; it stays loaded, and on-demand still shows it. "Hide while a game is live" skips it whenever any plugin has a live game, so on gameday you only see scores.</p>
</div>
{% if plugin_schedule_rows %}
<div class="space-y-3">
{% for row in plugin_schedule_rows %}
<details class="border border-gray-200 rounded-md">
<summary class="cursor-pointer px-4 py-3 text-sm font-medium text-gray-900">
{{ row.name }}
{% if row.schedule.enabled or row.schedule.hide_when_live %}
<span class="ml-2 text-xs text-blue-700">scheduled</span>
{% endif %}
</summary>
<form class="plugin-schedule-form px-4 pb-4 space-y-4"
hx-post="/api/v3/config/plugin-schedules"
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
hx-swap="none"
data-reports-result>
<input type="hidden" name="plugin_id" value="{{ row.id }}">
<label class="flex items-center text-sm text-gray-700">
<input type="checkbox" name="hide_when_live" class="mr-2"
{% if row.schedule.hide_when_live %}checked{% endif %}>
Hide while a game is live
</label>
<div id="plugin_schedule_picker_{{ loop.index }}"
data-plugin-schedule-config='{{ row.schedule | tojson }}'></div>
<div class="flex justify-end">
<button type="submit"
class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
<i class="fas fa-save mr-2"></i>
Save {{ row.name }} Schedule
</button>
</div>
</form>
</details>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-gray-600">No enabled plugins.</p>
{% endif %}
</div>
</div>