diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f4933b3..9dd7243d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,16 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### Added: plugin schedules + +Schedule → Plugin Schedules sets when each plugin may show, per day or with +one time range, and can hide a plugin whenever any game is live ("no news or +stocks on gameday"). A hidden plugin is skipped in the rotation, the live +takeover and Vegas mode, but stays loaded. New core config key +`plugin_schedules`, new `GET/POST /api/v3/config/plugin-schedules`, new +module `src/plugin_schedule.py` (the display schedule's window rule now +lives there too). + ### Removed: the cache-key mailboxes (control socket stage 5) -- breaking The control socket (`docs/IPC_CONTROL_SOCKET.md`) is now the only way the diff --git a/config/config.template.json b/config/config.template.json index f3541cf1..58ffae14 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -47,6 +47,7 @@ } } }, + "plugin_schedules": {}, "dim_schedule": { "enabled": false, "dim_brightness": 30, diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index fd33d14b..2563af09 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -55,6 +55,41 @@ saved via `POST /api/v3/config/dim-schedule`). The display returns to boundaries as `schedule`: dimmed from `start_time` up to, but not including, `end_time`. +## `plugin_schedules` — when each plugin may show + +Maps a plugin id to a schedule shaped like `schedule` (the window in which +that plugin may show), plus: + +| Key | Type / default | Meaning | +|---|---|---| +| `hide_when_live` | bool, `false` | Hide the plugin whenever any plugin reports a live game (its `has_live_content()`), live priority or not | + +A plugin with no entry always shows. Outside its window, or during a live +game with `hide_when_live`, a plugin is skipped in the rotation, in the live +takeover and in Vegas mode; it stays loaded, and an on-demand request still shows it. Example, news off on +Sundays and during any live game: + +```json +"plugin_schedules": { + "news": {"enabled": true, "mode": "per-day", "hide_when_live": true, + "start_time": "00:00", "end_time": "23:59", + "days": {"sunday": {"enabled": false}}} +} +``` + +As in `schedule`, a day missing from `days` uses the top-level times +(`07:00`–`23:00` when those are missing too). The window ends at `end_time`, +so `00:00`–`23:59` leaves the plugin out for that last minute. + +A change takes effect at the next screen in the rotation or live takeover, +and in Vegas mode at its next plugin-list refresh: the next scroll group, +or at the latest the next scroll cycle (about 6 minutes on ledpi with 8 +plugins). Content already composed into the ticker scrolls out first. + +Read by `src/plugin_schedule.py` through `DisplayController._hidden_plugins()`. +Managed in the web UI under Schedule → Plugin Schedules, saved via +`POST /api/v3/config/plugin-schedules`. + ## `display.hardware` — matrix panel hardware All keys map to the corresponding `rpi-rgb-led-matrix` options and are read diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index 5dc7f836..3c3731a4 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -2409,6 +2409,20 @@ enabled with default times `20:00`-`07:00`. As for the schedule above, an enabled dim schedule needs at least one day enabled and a disabled one may have every day off. +### Plugin Schedules + +**GET** `/api/v3/config/plugin-schedules` + +Every plugin's schedule (`plugin_schedules` in `docs/CONFIG_REFERENCE.md`), +keyed by plugin id. + +**POST** `/api/v3/config/plugin-schedules` + +Save one plugin's schedule: `plugin_id`, `hide_when_live`, and the same +fields as `POST /api/v3/config/schedule`. A schedule that is off and does +not hide during live games is removed. 400 for a core section name or a bad +time, 404 for a plugin that is not installed. + --- ## Integrations diff --git a/src/core_config_keys.py b/src/core_config_keys.py index 42e75969..81f78913 100644 --- a/src/core_config_keys.py +++ b/src/core_config_keys.py @@ -23,6 +23,7 @@ CORE_CONFIG_KEYS = frozenset({ 'auto_update', 'schedule', 'dim_schedule', + 'plugin_schedules', 'timezone', 'target_fps', 'location', diff --git a/src/display_controller.py b/src/display_controller.py index b5c8356a..d04115ac 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -20,6 +20,7 @@ Entry point: :func:`main` — instantiates :class:`DisplayController` and calls :meth:`~DisplayController.run`. """ +import logging import time import os import inspect @@ -37,6 +38,7 @@ import pytz from src import display_watchdog from src.malloc_tuning import MallocTrimmer +from src.plugin_schedule import hidden_plugins, in_window, schedule_state, wants_live_scan from src.display_arbiter import ( Arbiter, ArbiterInputs, ArbiterState, FramePolicy, ScreenPlan, Source, WifiNotice, live_pick, live_takeover, on_demand_bound, rotation_plan, @@ -705,6 +707,11 @@ class DisplayController: self._schedule_checked_minute: Optional[tuple] = None self._dim_checked_minute: Optional[tuple] = None self._cached_target_brightness: int = self._normal_brightness + # Per-plugin schedules (config['plugin_schedules'], src/plugin_schedule.py): + # (key, hidden set), see _hidden_plugins. + self._plugin_hidden_cache: Tuple[Optional[tuple], FrozenSet[str]] = (None, frozenset()) + self._game_live = False + self._game_live_at = float('-inf') # Register controller-level hot-reload callback so cached config values # (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync @@ -751,6 +758,8 @@ class DisplayController: # Set up live priority checker self.vegas_coordinator.set_live_priority_checker(self._check_live_priority) + # Plugins scheduled off sit out the ticker too. + self.vegas_coordinator.stream_manager.hidden_plugins = self._hidden_plugins # Set up interrupt checker for on-demand/wifi status and follower mode def _vegas_interrupt(): @@ -912,38 +921,19 @@ class DisplayController: self._tz = pytz.UTC return self._tz - @staticmethod - def _in_window(start, end, now) -> bool: - """Whether ``now`` is within [start, end), a window that may span midnight. - - Half-open: on from the start minute, off at exactly the end minute. - A closed end made the end minute count as inside, and because ``now`` - carries seconds, only a check at hh:mm:00.000 saw it that way -- so - whether the panel went off at the start or the end of that minute - depended on when the minute's one check ran. ``start == end`` is an - empty window, as it effectively was before. - """ - if start <= end: - return start <= now < end - return now >= start or now < end + # The window rule, shared with the dim schedule and plugin schedules. + _in_window = staticmethod(in_window) def _check_schedule(self): """Check if display should be active based on schedule.""" schedule_config = self.config.get('schedule', {}) - # No schedule configured: always active. - if not schedule_config: + # No schedule, or a disabled one: always active. + if not schedule_config or not schedule_config.get('enabled', True): self.is_display_active = True self._was_display_active = True return - # A schedule without an 'enabled' key counts as enabled. - if 'enabled' in schedule_config and not schedule_config.get('enabled', True): - self.is_display_active = True - self._was_display_active = True - logger.debug("Schedule is disabled - display always active") - return - current_time = datetime.now(self._timezone()) # Gate: schedule state can only change on a minute boundary, so skip # all the strptime / comparison work if we already evaluated this minute. @@ -952,81 +942,15 @@ class DisplayController: return self._schedule_checked_minute = current_minute_key - current_day = current_time.strftime('%A').lower() # e.g. 'monday' - current_time_only = current_time.time() - - # Check if per-day schedule is configured - days_config = schedule_config.get('days') - - # Determine which schedule to use. Respect an explicit 'mode' field - # (like the dim schedule does) so a stray/legacy 'days' dict left over - # from config migration or a prior per-day setup can't silently - # override a user's Global schedule selection. - mode = schedule_config.get('mode') - mode_normalized = mode.replace('_', '-') if mode else None - - use_per_day = False - if mode_normalized == 'global': - use_per_day = False - elif mode_normalized == 'per-day': - use_per_day = bool(days_config and current_day in days_config) - elif days_config: - # No explicit mode recorded (legacy config) - fall back to - # inferring from presence of a 'days' dict for the current day. - if current_day in days_config: - use_per_day = True - else: - logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day) - - if use_per_day: - day_config = days_config[current_day] - - if not day_config.get('enabled', True): - was_active = self._was_display_active - self.is_display_active = False - if was_active: - logger.info("Schedule activated: Display is now INACTIVE (%s is disabled in schedule). Display will be blanked.", current_day) - else: - logger.debug("Display inactive - %s is disabled in schedule", current_day) - self._was_display_active = self.is_display_active - return - - start_time_str = day_config.get('start_time', '07:00') - end_time_str = day_config.get('end_time', '23:00') - schedule_type = f"per-day ({current_day})" + active, why = schedule_state(schedule_config, current_time) + if active != self._was_display_active: + logger.info("Schedule activated: Display is now %s (%s)%s", + "ACTIVE" if active else "INACTIVE", why, + "" if active else ". Display will be blanked.") else: - start_time_str = schedule_config.get('start_time', '07:00') - end_time_str = schedule_config.get('end_time', '23:00') - schedule_type = "global" - - try: - start_time = datetime.strptime(start_time_str, '%H:%M').time() - end_time = datetime.strptime(end_time_str, '%H:%M').time() - self.is_display_active = self._in_window(start_time, end_time, current_time_only) - - was_active = self._was_display_active - if not self.is_display_active: - if was_active: - logger.info("Schedule activated: Display is now INACTIVE (outside %s schedule window %s - %s). Display will be blanked.", - schedule_type, start_time_str, end_time_str) - else: - logger.debug("Display inactive - outside %s schedule window (%s - %s)", - schedule_type, start_time_str, end_time_str) - else: - if not was_active: - logger.info("Schedule activated: Display is now ACTIVE (within %s schedule window %s - %s)", - schedule_type, start_time_str, end_time_str) - else: - logger.debug("Display active - within %s schedule window (%s - %s)", - schedule_type, start_time_str, end_time_str) - - self._was_display_active = self.is_display_active - - except ValueError as e: - logger.warning("Invalid schedule format for %s schedule: %s (start: %s, end: %s). Defaulting to active.", - schedule_type, e, start_time_str, end_time_str) - self.is_display_active = True - self._was_display_active = True + logger.debug("Display %s - %s", "active" if active else "inactive", why) + self.is_display_active = active + self._was_display_active = active def _check_dim_schedule(self) -> int: """ @@ -1505,8 +1429,18 @@ class DisplayController: if self._empty_pass_streak < rotation: return if self._empty_pass_streak == rotation: + # Blank once: with every plugin empty or scheduled off, the last + # screen drawn would otherwise stay up indefinitely. A blank the + # controller draws, so the last scroll's state ends first. + self._end_scroll_before_core_screen() + self.display_manager.clear() + self.display_manager.update_display() logger.info("No mode has anything to show; checking one mode every %.0fs " "until one does", self.EMPTY_ROTATION_PAUSE) + # The preview throttle may have skipped the blank frame, and with no + # display() call to follow, nothing else writes it (see + # DisplayManager.write_owed_snapshot). Cheap when nothing is owed. + self.display_manager.write_owed_snapshot() self._sleep_with_plugin_updates(self.EMPTY_ROTATION_PAUSE) #: Plugins already warned about a display duration that is not a number, @@ -2990,6 +2924,54 @@ class DisplayController: self.force_change = True logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode) + #: How long a "is any game live?" answer is reused for plugin schedules. + GAME_LIVE_TTL = 5.0 + + def _any_game_live(self) -> bool: + """Whether any plugin reports live content, asked at most every GAME_LIVE_TTL s. + + Unlike _collect_live_modes this asks every plugin, live priority or + not: a scoreboard without live priority still makes it gameday. + """ + now = time.monotonic() + if now - self._game_live_at >= self.GAME_LIVE_TTL: + self._game_live_at = now + live = False + for plugin in {id(p): p for p in list(self.plugin_modes.values())}.values(): + try: + if hasattr(plugin, 'has_live_content') and plugin.has_live_content(): + live = True + break + except Exception as e: # pylint: disable=broad-except + logger.warning("Error checking live content for %s: %s", + getattr(plugin, 'plugin_id', plugin), e) + self._game_live = live + return self._game_live + + def _hidden_plugins(self) -> FrozenSet[str]: + """Plugin ids their plugin_schedules entry keeps off the panel right now. + + Recomputed when the schedules, the minute or the live-game answer + change. Also Vegas's filter (StreamManager.hidden_plugins), from its + own thread: the schedules are part of the key and the cache is one + tuple, so a call still holding the config from before a reload + cannot leave its answer standing for the new one. + """ + schedules = self.config.get('plugin_schedules') + if not isinstance(schedules, dict) or not schedules: + return frozenset() + now = datetime.now(self._timezone()) + game_live = wants_live_scan(schedules) and self._any_game_live() + key = (schedules, now.hour, now.minute, game_live) + cached_key, hidden = self._plugin_hidden_cache + if key != cached_key: + previous = hidden + hidden = hidden_plugins(schedules, now, game_live) + if hidden != previous: + logger.info("Plugin schedules: hidden now %s", sorted(hidden) or "none") + self._plugin_hidden_cache = (key, hidden) + return hidden + def _collect_live_modes(self): """Return every currently live-priority mode, in registration order. @@ -3006,10 +2988,14 @@ class DisplayController: # Asked once per plugin per scan, not once per mode key: a scoreboard # registered under several modes computes the same answer each time. is_live: Dict[int, bool] = {} + # A scoreboard scheduled off does not win the panel by going live. + hidden = self._hidden_plugins() for mode_name, plugin_instance in self.plugin_modes.items(): if not (hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content')): continue + if getattr(plugin_instance, 'plugin_id', mode_name) in hidden: + continue try: key = id(plugin_instance) if key not in is_live: @@ -3468,6 +3454,20 @@ class DisplayController: logger.debug(f"Found plugin manager for mode {mode}: {type(plugin_instance).__name__}") return plugin_instance + def _plugin_to_draw(self, plan: ScreenPlan): + """The plugin to draw ``plan``'s mode, or None to skip it this pass. + + _plugin_for_mode, and None for a plugin its plugin_schedules entry + keeps off the panel right now: skipped like an empty screen. An + on-demand plan is the user asking, so it still shows. + """ + plugin = self._plugin_for_mode(plan.mode) + if (plugin is not None and plan.source is not Source.ON_DEMAND + and getattr(plugin, 'plugin_id', plan.mode) in self._hidden_plugins()): + logger.debug("%s is scheduled off, skipping", plan.mode) + return None + return plugin + def _start_screen_handover(self, plugin, active_mode: str) -> bool: """Before a screen's first dispatch: if the screen is static, keep the last scroll's leftovers off its first frame. @@ -3920,14 +3920,22 @@ class DisplayController: elif nxt is not state: self._adopt_state(nxt) self.force_change = True - logger.info("Switching to mode: %s", self.current_display_mode) + # DEBUG once a whole rotation has been empty (_note_empty_pass): + # it then steps once a second, and every plugin scheduled off + # would otherwise journal a line a second. + logger.log(logging.DEBUG if self._empty_pass_streak >= max(1, len(self.available_modes)) + else logging.INFO, + "Switching to mode: %s", self.current_display_mode) def _still_live(self, mode: Optional[str]) -> bool: """The live hold: ``mode``'s plugin has live priority and live content, - so the rotation stays on it.""" + so the rotation stays on it. Never for a plugin scheduled off: the + hold would keep the rotation on a mode it skips.""" if mode not in self.plugin_modes: return False plugin_instance = self.plugin_modes[mode] + if getattr(plugin_instance, 'plugin_id', mode) in self._hidden_plugins(): + return False if not (hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content')): return False @@ -4201,10 +4209,8 @@ class DisplayController: logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys())) # Handle plugin-based display modes - manager_to_display = self._plugin_for_mode(active_mode) - if not manager_to_display: - logger.warning(f"No plugin manager found for mode {active_mode} - skipping display and rotating to next mode") - manager_to_display = None + # None skips the mode; _plugin_to_draw has logged why. + manager_to_display = self._plugin_to_draw(plan) # One screen: the first frame, the frame loop, the make-up # dwell (src/screen_runner.py). diff --git a/src/plugin_schedule.py b/src/plugin_schedule.py new file mode 100644 index 00000000..34efd233 --- /dev/null +++ b/src/plugin_schedule.py @@ -0,0 +1,104 @@ +"""Schedule windows: the display's on/off schedule and per-plugin schedules. + +A schedule is the dict config.json keeps under ``schedule``:: + + {"enabled": true, "mode": "global" | "per-day", + "start_time": "07:00", "end_time": "23:00", + "days": {"monday": {"enabled": true, "start_time": ..., "end_time": ...}, ...}} + +``config['plugin_schedules']`` maps a plugin id to one of these, the window in +which that plugin may take a turn on the panel, plus ``hide_when_live``: hide +the plugin while any plugin reports a live game ("no news or stocks on +gameday"). A plugin without an entry always shows. Hiding is a skip at +display time, never the plugin's ``enabled`` flag: flipping that unloads and +reloads the plugin and throws away its data. +""" + +import logging +from datetime import datetime, time +from typing import Any, FrozenSet, Mapping, Tuple + +logger = logging.getLogger(__name__) + + +def in_window(start: time, end: time, now: time) -> bool: + """Whether ``now`` is within [start, end), a window that may span midnight. + + Half-open: on from the start minute, off at exactly the end minute. + A closed end made the end minute count as inside, and because ``now`` + carries seconds, only a check at hh:mm:00.000 saw it that way -- so + whether the panel went off at the start or the end of that minute + depended on when the minute's one check ran. ``start == end`` is an + empty window, as it effectively was before. + """ + if start <= end: + return start <= now < end + return now >= start or now < end + + +def schedule_state(schedule: Mapping[str, Any], now: datetime) -> Tuple[bool, str]: + """Whether ``now`` is inside ``schedule``, and a phrase saying why. + + No schedule, or ``enabled: false``, is always inside; a schedule without + an ``enabled`` key counts as enabled. ``mode`` "global" ignores ``days``; + "per-day" (or "per_day") uses today's entry and falls back to the global + times when today has none; with no ``mode`` (older configs) a ``days`` + entry for today wins. A disabled day is outside all day. A time that does + not parse is logged and counts as inside, so a bad edit never blanks the + panel. + """ + if not schedule or not schedule.get('enabled', True): + return True, 'no schedule' + + day = now.strftime('%A').lower() + # Wrong-shaped values (the raw config editor allows any) are treated as + # missing rather than raised: this runs in the display loop. + days = schedule.get('days') + if not isinstance(days, Mapping): + days = {} + mode = schedule.get('mode') + mode = mode.replace('_', '-') if isinstance(mode, str) else None + # An explicit Global mode beats a stray 'days' dict left from a past + # per-day setup. + per_day = mode != 'global' and isinstance(days.get(day), Mapping) + + if per_day: + day_config = days[day] + if not day_config.get('enabled', True): + return False, f'{day} is disabled' + start_str = day_config.get('start_time', '07:00') + end_str = day_config.get('end_time', '23:00') + kind = f'per-day ({day})' + else: + start_str = schedule.get('start_time', '07:00') + end_str = schedule.get('end_time', '23:00') + kind = 'global' + + try: + start = datetime.strptime(start_str, '%H:%M').time() + end = datetime.strptime(end_str, '%H:%M').time() + except (TypeError, ValueError) as e: + logger.warning("Invalid %s schedule: %s (start: %s, end: %s). Treating as inside.", + kind, e, start_str, end_str) + return True, f'invalid {kind} schedule' + return in_window(start, end, now.time()), f'{kind} window {start_str} - {end_str}' + + +def wants_live_scan(plugin_schedules: Mapping[str, Any]) -> bool: + """Whether any plugin hides during live games, so live content is worth asking about.""" + return any(isinstance(s, Mapping) and s.get('hide_when_live') + for s in plugin_schedules.values()) + + +def hidden_plugins(plugin_schedules: Mapping[str, Any], now: datetime, + game_live: bool) -> FrozenSet[str]: + """The plugin ids that may not show at ``now``. + + A plugin is hidden outside its schedule window, or while ``game_live`` + when its entry sets ``hide_when_live``. + """ + return frozenset( + plugin_id for plugin_id, schedule in plugin_schedules.items() + if isinstance(schedule, Mapping) + and ((game_live and schedule.get('hide_when_live')) + or not schedule_state(schedule, now)[0])) diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index e7467636..b3789a3a 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -303,7 +303,9 @@ class VegasModeCoordinator: # Initialize stream manager if not self.stream_manager.initialize(): - logger.error("Failed to initialize stream manager") + # Only fails with no plugin to show (every one excluded or + # scheduled off), which initialize() has logged once. + logger.debug("Vegas not started: no plugins to scroll") return False # Compose initial content diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py index ccbf7db2..252e7f4e 100644 --- a/src/vegas_mode/stream_manager.py +++ b/src/vegas_mode/stream_manager.py @@ -16,7 +16,7 @@ BasePlugin.get_vegas_participation): import logging import threading import time -from typing import Optional, List, Dict, Deque, Tuple, TYPE_CHECKING +from typing import Callable, Optional, List, Dict, Deque, FrozenSet, Tuple, TYPE_CHECKING from collections import deque from dataclasses import dataclass, field from PIL import Image @@ -75,6 +75,16 @@ class StreamManager: self.config = config self.plugin_manager = plugin_manager self.plugin_adapter = plugin_adapter + # Plugin ids to leave out right now (config['plugin_schedules']); + # the display controller sets it. Read at each list refresh. + # A change to it rebuilds the list at the next refresh() even inside + # the refresh interval; segments already composed still scroll out. + self.hidden_plugins: Optional[Callable[[], FrozenSet[str]]] = None + self._applied_hidden: FrozenSet[str] = frozenset() + # True once "no plugins" has been logged, until the list fills again: + # with every plugin scheduled off the display retries every second, + # and three log lines a second is a full journal on an SD card. + self._empty_logged = False # Segments composed into the current cycle (swap mode only). self._active_buffer: Deque[ContentSegment] = deque() @@ -106,8 +116,11 @@ class StreamManager: self._refresh_plugin_list() if not self._ordered_plugins: - logger.warning("No plugins available for Vegas scroll") + if not self._empty_logged: + logger.warning("No plugins available for Vegas scroll") + self._empty_logged = True return False + self._empty_logged = False # Fill the buffer to a whole cycle's worth of plugins. This used to be # buffer_ahead + 1, which conflated prefetch depth with cycle size and @@ -268,7 +281,9 @@ class StreamManager: Called periodically to pick up new plugins or config changes. """ current_time = time.time() - if current_time - self._last_refresh < self._refresh_interval: + hidden = self.hidden_plugins() if self.hidden_plugins else frozenset() + if (current_time - self._last_refresh < self._refresh_interval + and hidden == self._applied_hidden): return self._last_refresh = current_time @@ -295,10 +310,15 @@ class StreamManager: # own thread (DisplayController._start_plugin_reload). plugins = list(self.plugin_manager.plugins.items()) loaded = len(plugins) + hidden = self.hidden_plugins() if self.hidden_plugins else frozenset() + self._applied_hidden = hidden for plugin_id, plugin in plugins: if not getattr(plugin, 'enabled', False): logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id) continue + if plugin_id in hidden: + logger.debug("[%s] Vegas: skipped (scheduled off)", plugin_id) + continue # 'pause' plugins stay in the rotation: they pause the scroll # for their turn rather than contributing to it. @@ -329,7 +349,8 @@ class StreamManager: slots = (f", {len(ordered_plugins)} slots" if len(ordered_plugins) != len(set(ordered_plugins)) else "") - logger.info( + logger.log( + logging.DEBUG if (self._empty_logged and not ordered_plugins) else logging.INFO, "Vegas rotation: %d of %d loaded plugin(s)%s: %s", len(set(ordered_plugins)), loaded, slots, ', '.join(ordered_plugins) ) diff --git a/test/fixtures/api_v3_url_map.json b/test/fixtures/api_v3_url_map.json index 7beb1c09..b9a9ffcf 100644 --- a/test/fixtures/api_v3_url_map.json +++ b/test/fixtures/api_v3_url_map.json @@ -159,6 +159,23 @@ "POST" ] ], + [ + "/api/v3/config/plugin-schedules", + "api_v3.get_plugin_schedules", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/config/plugin-schedules", + "api_v3.save_plugin_schedule", + [ + "OPTIONS", + "POST" + ] + ], [ "/api/v3/config/raw/main", "api_v3.save_raw_main_config", diff --git a/test/js/dom/test_schedule_page.js b/test/js/dom/test_schedule_page.js index 4bb21d16..bc6387d4 100644 --- a/test/js/dom/test_schedule_page.js +++ b/test/js/dom/test_schedule_page.js @@ -39,8 +39,11 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l)) ok('the partial has no inline handlers', !/\son(click|input|change|submit)=/i.test(partial)); ok('the forms carry no hx-on handler', !/hx-on/i.test(partial)); ok('the partial root is data-page="schedule"', /data-page="schedule"/.test(partial)); - ok('both forms are marked data-reports-result', - (partial.match(/
+ + +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.
+No enabled plugins.
+ {% endif %} +