mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-12 22:28:06 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5538af9259 | ||
|
|
611eef0597 | ||
|
|
8c00df2e13 | ||
|
|
bb1a1671ec | ||
|
|
8159afca43 | ||
|
|
44f59ede07 |
@@ -129,6 +129,9 @@
|
||||
"plugin_rotation_order": [],
|
||||
"use_short_date_format": true,
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": false,
|
||||
"live_weight": 3,
|
||||
"favorite_live_weight": 5,
|
||||
"enabled": false,
|
||||
"scroll_speed": 50,
|
||||
"separator_width": 32,
|
||||
|
||||
@@ -64,10 +64,98 @@ JSON is optional.
|
||||
| `target_fps` | `125` | Target frame rate |
|
||||
| `buffer_ahead` | `2` | Number of plugins buffered ahead |
|
||||
|
||||
This table is a subset — `display.vegas_scroll` supports 26 keys in
|
||||
This table is a subset — `display.vegas_scroll` supports 30 keys in
|
||||
total. See the full list in
|
||||
[CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#displayvegas_scroll--continuous-scroll-mode).
|
||||
|
||||
### Live Content in the Ticker
|
||||
|
||||
By default, live content **preempts** Vegas mode: while any plugin reports
|
||||
live priority, the display controller refuses to run the ticker and shows
|
||||
that plugin's full-screen display instead. You get a big readable scoreboard,
|
||||
but the marquee stops entirely for the duration of the game.
|
||||
|
||||
Set `live_in_ticker` to keep the ticker running and let live content take
|
||||
**extra turns inside it** instead:
|
||||
|
||||
```json
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": true,
|
||||
"live_weight": 3,
|
||||
"favorite_live_weight": 5
|
||||
}
|
||||
```
|
||||
|
||||
#### Why weights exist
|
||||
|
||||
The rotation is otherwise a strict round robin — every plugin appears exactly
|
||||
once per cycle. With a dozen plugins enabled, a live score comes round once a
|
||||
lap and can be minutes old by the time you see it. A weight of *N* gives a
|
||||
plugin *N* slots per cycle.
|
||||
|
||||
The slots are placed by **Smooth Weighted Round-Robin**, the same scheduler
|
||||
the sports plugins use internally to rotate their own games. The important
|
||||
property is that repeats are *spread through the cycle* rather than clumped:
|
||||
three appearances in a row followed by a long silence would be worse than not
|
||||
boosting at all.
|
||||
|
||||
Twelve plugins, with a favorite's baseball game and an ordinary live hockey
|
||||
game (`live_weight: 3`, `favorite_live_weight: 5`):
|
||||
|
||||
```
|
||||
baseball > hockey > weather > clock > baseball
|
||||
stocks > news > flights > baseball > hockey
|
||||
calendar > f1 > music > baseball > tides
|
||||
birds > hockey > baseball
|
||||
```
|
||||
|
||||
18 slots for 12 plugins. Baseball appears 5 times, hockey 3, everything else
|
||||
once, and no plugin ever appears twice in a row — **including across the seam**
|
||||
where the cycle loops back on itself. Smooth Weighted Round-Robin schedules the
|
||||
heaviest item first and usually last as well, so the strip would otherwise show
|
||||
it twice running at exactly the one join a within-cycle check cannot see. The
|
||||
trailing repeat is moved into the widest remaining gap. Where a double is
|
||||
unavoidable — a plugin holding most of the slots has to neighbour itself — the
|
||||
schedule is left as it is.
|
||||
|
||||
#### Where the weight comes from
|
||||
|
||||
For each plugin in the rotation, in order:
|
||||
|
||||
1. **The plugin's own answer.** If it implements
|
||||
`get_vegas_priority_weight()` and returns a number, that wins. This is the
|
||||
only route for favorite-team awareness — the core can see *that* a game is
|
||||
live, but not *whose*, so a scoreboard has to say so itself.
|
||||
2. **The core's default.** When the plugin returns `None` (the base-class
|
||||
default), a plugin where both `has_live_priority()` and `has_live_content()`
|
||||
are true gets `live_weight`.
|
||||
3. **Everything else** gets 1.
|
||||
|
||||
Because of step 2, **existing plugins need no changes** — any scoreboard with
|
||||
`live_priority` enabled already gets extra turns. Step 1 is opt-in, for
|
||||
plugins that want to distinguish a favorite's game from any other live game.
|
||||
|
||||
Weights are clamped to 1–10. A weight of 1 is no boost; a weight below 1 would
|
||||
drop the plugin from the rotation entirely, which is never what is meant.
|
||||
|
||||
#### Things worth knowing
|
||||
|
||||
- **Weights are per plugin, not per game.** A scoreboard showing four live
|
||||
games still occupies one slot at a time, rotating its own games within that
|
||||
slot using its own `favorite_live_boost`. This controls how often the
|
||||
*plugin* comes round.
|
||||
- **The ticker is zero-sum.** Giving baseball 5 slots does not make the cycle
|
||||
faster; it makes the cycle *longer* and everything else proportionally
|
||||
rarer. If you want live scores sooner in wall-clock terms, pair this with a
|
||||
smaller `plugins_per_cycle`.
|
||||
- **Frequency is not freshness.** Each appearance redraws from the plugin's
|
||||
current data (`refresh_updated_plugins()` drops cached content when a
|
||||
plugin's data changes), but how current that data is depends on the
|
||||
plugin's own `live_update_interval`. Showing a stale score five times a lap
|
||||
is no better than showing it once.
|
||||
- **Everything still appears.** A boost never starves another plugin out of
|
||||
the cycle; low-weight plugins keep their single slot.
|
||||
|
||||
### Per-Plugin Configuration
|
||||
|
||||
Override Vegas behavior for specific plugins:
|
||||
|
||||
@@ -103,7 +103,8 @@ logical image to multiple chained physical panels.
|
||||
## `display.vegas_scroll` — continuous scroll mode
|
||||
|
||||
Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details.
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details, including
|
||||
[live content in the ticker](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||
|
||||
| Key | Type / default |
|
||||
|---|---|
|
||||
@@ -134,6 +135,9 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
| `max_cycle_duration` | int, `240` |
|
||||
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
|
||||
| `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) |
|
||||
| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
|
||||
| `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content |
|
||||
| `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live |
|
||||
|
||||
## `sync` — multi-display synchronization
|
||||
|
||||
|
||||
@@ -170,6 +170,47 @@ Default returns `False`.
|
||||
List of display modes to show during a live takeover. Default returns the
|
||||
plugin's `display_modes` from its manifest.
|
||||
|
||||
#### `get_vegas_priority_weight() -> Optional[int]`
|
||||
|
||||
How many slots per Vegas cycle this plugin should get. Default returns
|
||||
`None`, which defers to the core.
|
||||
|
||||
The Vegas ticker is otherwise a strict round robin — every plugin appears
|
||||
exactly once per cycle — so with a dozen plugins enabled a live score can be
|
||||
minutes stale by the time it comes round. A weight of *N* gives the plugin
|
||||
*N* slots per cycle, spread evenly through it rather than clumped.
|
||||
|
||||
**You usually do not need this.** When the hook returns `None`, the core
|
||||
already gives a plugin `vegas_scroll.live_weight` whenever
|
||||
`has_live_priority()` and `has_live_content()` are both true. Live sports get
|
||||
extra turns with no code at all.
|
||||
|
||||
Implement it only when the plugin knows something the core cannot. The
|
||||
motivating case is favorite teams — the core can see *that* a game is live,
|
||||
but not *whose*:
|
||||
|
||||
```python
|
||||
def get_vegas_priority_weight(self):
|
||||
if not (self.has_live_priority() and self.has_live_content()):
|
||||
return None # let the core decide
|
||||
vegas = self.global_config.get('display', {}).get('vegas_scroll', {})
|
||||
if self._favorite_is_live():
|
||||
return vegas.get('favorite_live_weight', 5)
|
||||
return vegas.get('live_weight', 3)
|
||||
```
|
||||
|
||||
The weight is per *plugin*, not per game: a scoreboard showing four live games
|
||||
still occupies one slot at a time and rotates its own games within it. Values
|
||||
are clamped to 1–10 by the caller. An exception here is caught and logged, and
|
||||
the core then falls back to its own live-content check — so a plugin whose
|
||||
weight calculation is broken still gets `live_weight` for a game that really
|
||||
is live, rather than being demoted to 1.
|
||||
|
||||
Only consulted when the user has set `vegas_scroll.live_in_ticker`. With the
|
||||
default (`false`) live content preempts Vegas entirely and there is no ticker
|
||||
to be weighted within. See
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||
|
||||
### Vegas scroll hooks
|
||||
|
||||
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
||||
|
||||
Vendored
+16
@@ -112,6 +112,22 @@ class DiskCache:
|
||||
record_ts = None
|
||||
|
||||
now = time.time()
|
||||
|
||||
# An explicit per-entry ttl wins over the caller's max_age. The
|
||||
# caller that wrote the record knows what its data is; max_age is
|
||||
# inferred from substrings in the key ("live", "odds", "stock") and
|
||||
# is only a fallback for records that never said. Until now the ttl
|
||||
# was stored and ignored, so `set(key, data, ttl=...)` did nothing
|
||||
# at all -- 48 plugin call sites and 4 in the core were writing a
|
||||
# number no read path consulted.
|
||||
effective_max_age = max_age
|
||||
if isinstance(record, dict):
|
||||
stored_ttl = record.get('ttl')
|
||||
if isinstance(stored_ttl, (int, float)) and not isinstance(stored_ttl, bool) \
|
||||
and stored_ttl >= 0:
|
||||
effective_max_age = stored_ttl
|
||||
max_age = effective_max_age
|
||||
|
||||
# max_age=None means "never expires" (mirrors MemoryCache and the
|
||||
# cache_manager docstring). Guard it explicitly — otherwise the
|
||||
# comparison below raises TypeError and the record is treated as a
|
||||
|
||||
Vendored
+10
@@ -57,6 +57,16 @@ class MemoryCache:
|
||||
if timestamp is None:
|
||||
return None
|
||||
|
||||
# An explicit per-entry ttl wins over the caller's max_age, matching
|
||||
# DiskCache. max_age is inferred from substrings in the key and is
|
||||
# only a fallback for records that did not say what they wanted.
|
||||
record = self._cache[key]
|
||||
if isinstance(record, dict):
|
||||
stored_ttl = record.get('ttl')
|
||||
if isinstance(stored_ttl, (int, float)) and not isinstance(stored_ttl, bool) \
|
||||
and stored_ttl >= 0:
|
||||
max_age = stored_ttl
|
||||
|
||||
# Check expiration
|
||||
if max_age is not None and (now - timestamp) > max_age:
|
||||
# Expired - remove it
|
||||
|
||||
@@ -594,8 +594,10 @@ class CacheManager:
|
||||
Args:
|
||||
key: Cache key
|
||||
data: Data to cache
|
||||
ttl: Optional time-to-live in seconds (stored for compatibility but
|
||||
expiration is still controlled via max_age when reading)
|
||||
ttl: Time-to-live in seconds for this entry. Takes precedence over
|
||||
the max_age a reader would otherwise apply, which is inferred
|
||||
from the key and is only a fallback for entries that did not
|
||||
say. Omit it to keep that inferred behaviour.
|
||||
"""
|
||||
cache_data = {
|
||||
'data': data,
|
||||
|
||||
@@ -1638,6 +1638,12 @@ class DisplayController:
|
||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||
return live
|
||||
|
||||
def _vegas_keeps_live_in_ticker(self) -> bool:
|
||||
"""Whether live content should stay in the ticker instead of preempting it."""
|
||||
coordinator = getattr(self, 'vegas_coordinator', None)
|
||||
config = getattr(coordinator, 'vegas_config', None)
|
||||
return bool(getattr(config, 'live_in_ticker', False))
|
||||
|
||||
def _check_live_priority(self, advance=False):
|
||||
"""Return the live-priority mode to display, or None if nothing is live.
|
||||
|
||||
@@ -1851,14 +1857,24 @@ class DisplayController:
|
||||
# Check for live priority content and switch to it immediately.
|
||||
# advance=True so multiple simultaneously-live games take turns
|
||||
# (round-robin) instead of pinning to the first plugin.
|
||||
if not self.on_demand_active and not wifi_status_data:
|
||||
# Skipped when the ticker is keeping live content: switching
|
||||
# the rotation underneath Vegas would move current_mode_index
|
||||
# and stash a resume point for a takeover that never happens.
|
||||
if (not self.on_demand_active and not wifi_status_data
|
||||
and not (self._is_vegas_mode_active()
|
||||
and self._vegas_keeps_live_in_ticker())):
|
||||
live_priority_mode = self._check_live_priority(advance=True)
|
||||
self._apply_live_priority(live_priority_mode)
|
||||
|
||||
# Vegas scroll mode - continuous ticker across all plugins
|
||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||
if self._is_vegas_mode_active() and not wifi_status_data:
|
||||
live_mode = self._check_live_priority()
|
||||
# Live content normally preempts the ticker entirely. With
|
||||
# vegas_scroll.live_in_ticker the marquee keeps running and
|
||||
# the live plugin takes extra turns inside it instead --
|
||||
# see StreamManager._apply_priority_weights.
|
||||
live_mode = (None if self._vegas_keeps_live_in_ticker()
|
||||
else self._check_live_priority())
|
||||
if not live_mode:
|
||||
try:
|
||||
# Run Vegas mode iteration
|
||||
|
||||
@@ -555,6 +555,48 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
def get_vegas_priority_weight(self) -> Optional[int]:
|
||||
"""How many slots per Vegas cycle this plugin should get, or None.
|
||||
|
||||
The Vegas ticker is otherwise a strict round robin: every plugin
|
||||
appears exactly once per cycle. With a dozen plugins enabled that puts
|
||||
minutes between a live score and its next appearance. A weight of N
|
||||
gives the plugin N slots per cycle, spread evenly through it rather
|
||||
than clumped together.
|
||||
|
||||
Return ``None`` (the default) to let the core decide. It gives a
|
||||
plugin ``vegas_scroll.live_weight`` when ``has_live_priority()`` and
|
||||
``has_live_content()`` are both true, and 1 otherwise -- so live sports
|
||||
already get extra turns without implementing this at all.
|
||||
|
||||
Implement it only when the plugin knows something the core cannot. The
|
||||
motivating case is favorite teams: the core can see *that* a game is
|
||||
live but not *whose*, so a scoreboard that wants its favorite's game
|
||||
shown more often than other live games has to say so::
|
||||
|
||||
def get_vegas_priority_weight(self):
|
||||
if not (self.has_live_priority() and self.has_live_content()):
|
||||
return None # let the core decide
|
||||
cfg = self.global_config.get('display', {}).get('vegas_scroll', {})
|
||||
if self._favorite_is_live():
|
||||
return cfg.get('favorite_live_weight', 5)
|
||||
return cfg.get('live_weight', 3)
|
||||
|
||||
The weight is per *plugin*, not per game. A scoreboard showing four
|
||||
live games still occupies one slot at a time and rotates its own games
|
||||
within that slot; this controls how often the plugin itself comes
|
||||
round.
|
||||
|
||||
Raising is safe: the core logs it and falls back to its own
|
||||
live-content check, so a broken weight calculation costs the plugin
|
||||
the favorite distinction but not the live boost.
|
||||
|
||||
Returns:
|
||||
Slots per cycle (clamped to 1..10 by the caller), or None to
|
||||
defer to the core's own live-content weighting.
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_live_modes(self) -> List[str]:
|
||||
"""
|
||||
Get list of display modes that should be used during live priority takeover.
|
||||
|
||||
@@ -125,6 +125,32 @@ class VegasModeConfig:
|
||||
plugin_order: List[str] = field(default_factory=list)
|
||||
excluded_plugins: Set[str] = field(default_factory=set)
|
||||
|
||||
# --- Live content in the ticker -------------------------------------
|
||||
#
|
||||
# By default a live game preempts Vegas entirely: the display controller
|
||||
# refuses to run the ticker while any plugin reports live priority, and you
|
||||
# get the full-screen scoreboard instead. Set live_in_ticker to keep the
|
||||
# marquee running and let live content take extra turns within it.
|
||||
#
|
||||
# The rotation is otherwise a strict round robin -- every plugin appears
|
||||
# exactly once per cycle -- so with a dozen plugins enabled a live score
|
||||
# comes round once a lap and can be minutes old on screen. Weighting lets a
|
||||
# plugin claim several slots per cycle instead.
|
||||
#
|
||||
# Weights are per plugin, not per game: a scoreboard showing four live
|
||||
# games still occupies one slot at a time, and rotates its own games within
|
||||
# that slot using its own favorite_live_boost.
|
||||
live_in_ticker: bool = False
|
||||
|
||||
# Slots per cycle for a plugin reporting live content. 1 disables the boost
|
||||
# and restores the plain round robin.
|
||||
live_weight: int = 3
|
||||
|
||||
# Slots per cycle for a plugin whose live content involves a favorite team.
|
||||
# Only plugins implementing get_vegas_priority_weight() can claim this --
|
||||
# the core cannot tell whose game is on, so the plugin reports it.
|
||||
favorite_live_weight: int = 5
|
||||
|
||||
# Performance settings
|
||||
target_fps: int = 125 # Target frame rate
|
||||
buffer_ahead: int = 2 # Number of plugins to buffer ahead
|
||||
@@ -175,6 +201,12 @@ class VegasModeConfig:
|
||||
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
||||
plugin_order=list(vegas_config.get('plugin_order', [])),
|
||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
||||
live_in_ticker=bool(vegas_config.get('live_in_ticker', False)),
|
||||
# Clamped: a weight below 1 would drop the plugin from the rotation
|
||||
# entirely, and a very large one starves everything else.
|
||||
live_weight=max(1, min(10, int(vegas_config.get('live_weight', 3)))),
|
||||
favorite_live_weight=max(
|
||||
1, min(10, int(vegas_config.get('favorite_live_weight', 5)))),
|
||||
target_fps=int(vegas_config.get('target_fps', 125)),
|
||||
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)),
|
||||
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True),
|
||||
@@ -204,6 +236,9 @@ class VegasModeConfig:
|
||||
'lead_in_width': self.lead_in_width,
|
||||
'plugins_per_cycle': self.plugins_per_cycle,
|
||||
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
||||
'live_in_ticker': self.live_in_ticker,
|
||||
'live_weight': self.live_weight,
|
||||
'favorite_live_weight': self.favorite_live_weight,
|
||||
'overflow_mode': self.overflow_mode,
|
||||
'plugin_order': self.plugin_order,
|
||||
'excluded_plugins': list(self.excluded_plugins),
|
||||
@@ -371,6 +406,15 @@ class VegasModeConfig:
|
||||
|
||||
if 'enabled' in vegas_config:
|
||||
self.enabled = vegas_config['enabled']
|
||||
if 'live_in_ticker' in vegas_config:
|
||||
self.live_in_ticker = bool(vegas_config['live_in_ticker'])
|
||||
# Clamped exactly as from_config does: a weight below 1 would drop the
|
||||
# plugin from the rotation, and a huge one starves everything else.
|
||||
if 'live_weight' in vegas_config:
|
||||
self.live_weight = max(1, min(10, int(vegas_config['live_weight'])))
|
||||
if 'favorite_live_weight' in vegas_config:
|
||||
self.favorite_live_weight = max(
|
||||
1, min(10, int(vegas_config['favorite_live_weight'])))
|
||||
if 'scroll_speed' in vegas_config:
|
||||
self.scroll_speed = float(vegas_config['scroll_speed'])
|
||||
if 'separator_width' in vegas_config:
|
||||
|
||||
@@ -497,6 +497,12 @@ class VegasModeCoordinator:
|
||||
if not self._live_priority_check:
|
||||
return False
|
||||
|
||||
if self.vegas_config.live_in_ticker:
|
||||
# The ticker keeps live content rather than yielding to it; the
|
||||
# extra turns are arranged in the rotation itself, so there is
|
||||
# nothing to pause for.
|
||||
return False
|
||||
|
||||
try:
|
||||
live_mode = self._live_priority_check()
|
||||
if live_mode:
|
||||
|
||||
@@ -406,6 +406,8 @@ class StreamManager:
|
||||
)
|
||||
logger.info("Ordered plugins: %s", ordered_plugins)
|
||||
|
||||
ordered_plugins = self._apply_priority_weights(ordered_plugins)
|
||||
|
||||
# Atomically update shared state under lock to avoid races with prefetchers
|
||||
with self._buffer_lock:
|
||||
self._ordered_plugins = ordered_plugins
|
||||
@@ -417,6 +419,130 @@ class StreamManager:
|
||||
|
||||
logger.info("=" * 60)
|
||||
|
||||
def _plugin_weight(self, plugin_id: str) -> int:
|
||||
"""Slots per cycle for one plugin.
|
||||
|
||||
A plugin may answer for itself via get_vegas_priority_weight() -- the
|
||||
only way favorite-team awareness can reach here, since the core can see
|
||||
that a game is live but not whose. When it declines (returns None, the
|
||||
default), live content earns ``live_weight`` and everything else 1.
|
||||
"""
|
||||
plugin = None
|
||||
try:
|
||||
plugin = self.plugin_manager.plugins.get(plugin_id)
|
||||
except (AttributeError, TypeError):
|
||||
return 1
|
||||
if plugin is None:
|
||||
return 1
|
||||
|
||||
try:
|
||||
if hasattr(plugin, 'get_vegas_priority_weight'):
|
||||
declared = plugin.get_vegas_priority_weight()
|
||||
if declared is not None:
|
||||
return max(1, min(10, int(declared)))
|
||||
except Exception:
|
||||
# Deliberately falls through to the core's own live check rather
|
||||
# than demoting to 1. The plugin's weight calculation is broken,
|
||||
# but has_live_priority() and has_live_content() are separate
|
||||
# methods guarded separately below -- a plugin that genuinely has
|
||||
# a live game should still get live_weight for it.
|
||||
logger.exception("[%s] get_vegas_priority_weight() failed", plugin_id)
|
||||
|
||||
try:
|
||||
if (hasattr(plugin, 'has_live_priority')
|
||||
and hasattr(plugin, 'has_live_content')
|
||||
and plugin.has_live_priority()
|
||||
and plugin.has_live_content()):
|
||||
return self.config.live_weight
|
||||
except Exception:
|
||||
logger.exception("[%s] live-content check failed", plugin_id)
|
||||
return 1
|
||||
|
||||
def _apply_priority_weights(self, ordered: List[str]) -> List[str]:
|
||||
"""Expand the rotation so weighted plugins take several turns per cycle.
|
||||
|
||||
Smooth Weighted Round-Robin, the same scheduler the sports plugins use
|
||||
to rotate their own games: a plugin of weight N appears N times per
|
||||
cycle, and the repeats are spaced through the cycle rather than
|
||||
clumped, so a live score is never three-in-a-row followed by a long
|
||||
silence.
|
||||
|
||||
Returns the input unchanged when nothing is weighted, which is both the
|
||||
common case and the pre-existing behaviour.
|
||||
"""
|
||||
if not ordered or not self.config.live_in_ticker:
|
||||
return ordered
|
||||
|
||||
weights = {pid: self._plugin_weight(pid) for pid in ordered}
|
||||
total = sum(weights.values())
|
||||
if total <= len(ordered):
|
||||
return ordered # nothing boosted; plain round robin
|
||||
|
||||
current = {pid: 0 for pid in ordered}
|
||||
schedule: List[str] = []
|
||||
for _ in range(total):
|
||||
for pid in ordered:
|
||||
current[pid] += weights[pid]
|
||||
picked = max(current, key=lambda p: current[p])
|
||||
current[picked] -= total
|
||||
schedule.append(picked)
|
||||
|
||||
schedule = self._unclump_seam(schedule)
|
||||
|
||||
boosted = {p: w for p, w in weights.items() if w > 1}
|
||||
logger.info(
|
||||
"Vegas rotation weighted: %d slots for %d plugins (boosted: %s)",
|
||||
len(schedule), len(ordered), boosted)
|
||||
return schedule
|
||||
|
||||
@staticmethod
|
||||
def _unclump_seam(schedule: List[str]) -> List[str]:
|
||||
"""Stop the heaviest plugin sitting on both ends of the cycle.
|
||||
|
||||
Smooth Weighted Round-Robin spaces repeats well *within* a pass, but
|
||||
it schedules the heaviest item first and often last too. The strip
|
||||
loops, so those two are neighbours: the one place the marquee shows
|
||||
the same plugin twice running is the seam between cycles.
|
||||
|
||||
Rotating the list cannot fix this. Rotation preserves the cyclic order
|
||||
exactly, so it only moves where the seam is drawn, not the adjacency
|
||||
itself. The trailing entry has to be swapped with one from the middle
|
||||
whose neighbours differ from it, which breaks the pair without
|
||||
creating another.
|
||||
|
||||
Left alone when no such position exists -- a rotation short enough or
|
||||
lopsided enough to have none is one where the plugin is unavoidably
|
||||
adjacent to itself anyway.
|
||||
"""
|
||||
if len(schedule) < 3 or schedule[0] != schedule[-1]:
|
||||
return schedule
|
||||
|
||||
repeated = schedule[-1]
|
||||
size = len(schedule)
|
||||
elsewhere = [i for i, p in enumerate(schedule[:-1]) if p == repeated]
|
||||
|
||||
def clearance(j: int) -> int:
|
||||
"""Cyclic distance from j to the nearest other appearance."""
|
||||
return min(min((i - j) % size, (j - i) % size) for i in elsewhere)
|
||||
|
||||
candidates = [
|
||||
j for j in range(1, size - 1)
|
||||
if schedule[j] != repeated
|
||||
and schedule[j - 1] != repeated
|
||||
and schedule[j + 1] != repeated
|
||||
]
|
||||
if not candidates:
|
||||
return schedule
|
||||
|
||||
# Drop it into the widest gap rather than the first slot that fits.
|
||||
# Taking the first one undoes the spacing this whole function exists
|
||||
# to protect: on a 28-slot rotation it moved a repeat from a gap of 7
|
||||
# to a gap of 2, which is more clumped than the seam ever was.
|
||||
best = max(candidates, key=clearance) if elsewhere else candidates[0]
|
||||
schedule = list(schedule)
|
||||
schedule[best], schedule[-1] = schedule[-1], schedule[best]
|
||||
return schedule
|
||||
|
||||
def _prefetch_content(self, count: int = 1) -> None:
|
||||
"""
|
||||
Prefetch content for upcoming plugins.
|
||||
|
||||
@@ -4,6 +4,7 @@ Centralized error handling for web interface.
|
||||
Provides helpers for consistent error responses across API endpoints.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from flask import jsonify
|
||||
|
||||
@@ -16,6 +17,78 @@ from src.logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Credentials that turn up inside exception text. A requests error quotes the
|
||||
# URL it failed on, and plugins that authenticate by query string put their key
|
||||
# there, so echoing an exception verbatim can hand out an API key. Redact the
|
||||
# value, keep the parameter name -- knowing *which* credential was involved is
|
||||
# part of the diagnosis.
|
||||
_REDACT_CREDENTIAL = re.compile(
|
||||
r'((?:api[_-]?key|access[_-]?token|auth|apikey|key|passwd|password|pwd|'
|
||||
r'secret|sig|signature|token)["\']?\s*[=:]\s*["\']?)([^\s&"\'<>,}]+)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# `Authorization: <scheme> <credential>`. The scheme name is kept because it
|
||||
# says which kind of credential failed; the credential goes. Any scheme
|
||||
# matches, not a fixed list: ApiKey, Negotiate, NTLM, AWS4-HMAC-SHA256 and
|
||||
# whatever a plugin's API invents next are all credentials, and a list would
|
||||
# silently leak the ones nobody thought of. Not covered by the generic pattern
|
||||
# above, whose value part stops at whitespace and so would keep the credential
|
||||
# once a space follows the scheme.
|
||||
_REDACT_AUTH_HEADER = re.compile(
|
||||
r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*'
|
||||
r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept
|
||||
r'([^\s,"\'<>}]+)', # the credential, redacted
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Credentials embedded in a URL: https://user:password@host. requests quotes
|
||||
# the full URL in its exceptions, so this is a realistic leak. The username is
|
||||
# kept -- it identifies which account failed without being the secret.
|
||||
_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
|
||||
re.IGNORECASE)
|
||||
|
||||
# Long enough for an errno string with a path, short enough not to dump a
|
||||
# parser's worth of context into a JSON field.
|
||||
_MAX_DETAIL_LENGTH = 400
|
||||
|
||||
|
||||
def describe_exception(exc: BaseException,
|
||||
max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
||||
"""
|
||||
One-line, safe-to-return description of an exception.
|
||||
|
||||
The generic "an error occurred; see logs for details" tells a user nothing
|
||||
and, when the failure is bad enough, the logs are unreachable too: a device
|
||||
whose storage was failing returned that message from every endpoint
|
||||
*including* the log viewer, because journalctl could not be executed. The
|
||||
underlying `[Errno 5] Input/output error` named the fault immediately.
|
||||
|
||||
Returns "TypeName: message", credentials redacted and length capped. The
|
||||
type alone is worth carrying -- a bare PermissionError says more than any
|
||||
generic sentence.
|
||||
|
||||
Args:
|
||||
exc: The exception to describe
|
||||
max_length: Truncate beyond this many characters
|
||||
|
||||
Returns:
|
||||
A single-line description, never empty
|
||||
"""
|
||||
message = str(exc).strip()
|
||||
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
||||
# Order matters: the URL and header forms are more specific than the
|
||||
# generic key=value pattern, which would otherwise chew the scheme.
|
||||
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
|
||||
text = _REDACT_AUTH_HEADER.sub(r'\1<redacted>', text)
|
||||
text = _REDACT_CREDENTIAL.sub(r'\1<redacted>', text)
|
||||
# Collapse newlines/tabs so the detail stays one line in a JSON field.
|
||||
text = ' '.join(text.split())
|
||||
if len(text) > max_length:
|
||||
text = text[:max_length - 1].rstrip() + '…'
|
||||
return text
|
||||
|
||||
|
||||
def create_error_response(
|
||||
error_code: ErrorCode,
|
||||
message: str,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests that a per-entry ttl actually controls expiry.
|
||||
|
||||
Regression under test: `CacheManager.set(key, data, ttl=...)` stored the value
|
||||
and no read path ever consulted it. Expiry came from a `max_age` inferred from
|
||||
substrings in the key ("live", "odds", "stock"), so every caller passing `ttl=`
|
||||
-- 48 sites across the plugins and 4 in the core -- was writing a number that
|
||||
did nothing. The old docstring admitted as much: "stored for compatibility but
|
||||
expiration is still controlled via max_age when reading".
|
||||
|
||||
Measured against a real device's cache (8,873 entries carrying a ttl), the
|
||||
inferred value and the intended one disagreed almost everywhere:
|
||||
|
||||
stocks max_age 600 vs ttl 1800 4903 entries
|
||||
news max_age 3600 vs ttl 600 1770 entries
|
||||
odds max_age 1800 vs ttl 3600 1301 entries
|
||||
images max_age 300 vs ttl 2592000 20 entries
|
||||
|
||||
No `sports_live` entry carries a ttl, so live scores keep their inferred
|
||||
30-second freshness either way.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from src.cache.memory_cache import MemoryCache
|
||||
from src.cache.disk_cache import DiskCache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disk(tmp_path):
|
||||
return DiskCache(cache_dir=str(tmp_path))
|
||||
|
||||
|
||||
def _record(ttl=None, age=0.0):
|
||||
rec = {"data": {"v": 1}, "timestamp": time.time() - age}
|
||||
if ttl is not None:
|
||||
rec["ttl"] = ttl
|
||||
return rec
|
||||
|
||||
|
||||
class TestDiskCacheHonoursTtl:
|
||||
def test_ttl_longer_than_max_age_keeps_the_entry(self, disk):
|
||||
# The odds case: written wanting an hour, expired at 30 minutes.
|
||||
disk.set("odds_espn_football_nfl_401", _record(ttl=3600, age=1900))
|
||||
assert disk.get("odds_espn_football_nfl_401", max_age=1800) is not None
|
||||
|
||||
def test_ttl_shorter_than_max_age_expires_the_entry(self, disk):
|
||||
# The news case: written wanting 10 minutes, kept for an hour.
|
||||
disk.set("news_NHL_1", _record(ttl=600, age=900))
|
||||
assert disk.get("news_NHL_1", max_age=3600) is None
|
||||
|
||||
def test_without_a_ttl_max_age_still_applies(self, disk):
|
||||
disk.set("plain_key", _record(age=400))
|
||||
assert disk.get("plain_key", max_age=300) is None
|
||||
disk.set("plain_key2", _record(age=100))
|
||||
assert disk.get("plain_key2", max_age=300) is not None
|
||||
|
||||
def test_a_fresh_entry_within_its_ttl_survives(self, disk):
|
||||
disk.set("k", _record(ttl=600, age=10))
|
||||
assert disk.get("k", max_age=30) is not None
|
||||
|
||||
def test_ttl_zero_expires_immediately(self, disk):
|
||||
# 0 means zero seconds, not "forever" -- max_age=None is how a caller
|
||||
# asks for no expiry.
|
||||
disk.set("k", _record(ttl=0, age=1))
|
||||
assert disk.get("k", max_age=99999) is None
|
||||
|
||||
@pytest.mark.parametrize("bad", ["600", None, True, False, -5, {"a": 1}])
|
||||
def test_a_nonsense_ttl_falls_back_to_max_age(self, disk, bad):
|
||||
# Including bools: True is an int in Python and must not become a 1s ttl.
|
||||
rec = _record(age=400)
|
||||
rec["ttl"] = bad
|
||||
disk.set("k_%s" % type(bad).__name__, rec)
|
||||
assert disk.get("k_%s" % type(bad).__name__, max_age=300) is None
|
||||
|
||||
|
||||
class TestMemoryCacheHonoursTtl:
|
||||
def test_ttl_longer_than_max_age_keeps_the_entry(self):
|
||||
m = MemoryCache()
|
||||
m.set("k", _record(ttl=3600))
|
||||
m._timestamps["k"] = time.time() - 1900
|
||||
assert m.get("k", max_age=1800) is not None
|
||||
|
||||
def test_ttl_shorter_than_max_age_expires_the_entry(self):
|
||||
m = MemoryCache()
|
||||
m.set("k", _record(ttl=600))
|
||||
m._timestamps["k"] = time.time() - 900
|
||||
assert m.get("k", max_age=3600) is None
|
||||
|
||||
def test_without_a_ttl_max_age_still_applies(self):
|
||||
m = MemoryCache()
|
||||
m.set("k", _record())
|
||||
m._timestamps["k"] = time.time() - 400
|
||||
assert m.get("k", max_age=300) is None
|
||||
|
||||
def test_both_layers_agree(self, tmp_path):
|
||||
"""A record must not be live in one layer and expired in the other."""
|
||||
rec = _record(ttl=3600, age=1900)
|
||||
d = DiskCache(cache_dir=str(tmp_path))
|
||||
d.set("k", rec)
|
||||
m = MemoryCache()
|
||||
m.set("k", rec)
|
||||
m._timestamps["k"] = rec["timestamp"]
|
||||
assert (d.get("k", max_age=1800) is not None) == (m.get("k", max_age=1800) is not None)
|
||||
|
||||
|
||||
class TestEndToEnd:
|
||||
def test_set_then_get_respects_the_ttl(self, tmp_path, monkeypatch):
|
||||
"""The behaviour a caller of CacheManager.set(ttl=...) expects."""
|
||||
from src.cache_manager import CacheManager
|
||||
|
||||
cm = CacheManager()
|
||||
cm._disk_cache_component = DiskCache(cache_dir=str(tmp_path))
|
||||
cm._memory_cache_component = MemoryCache()
|
||||
|
||||
cm.set("odds_espn_football_nfl_401", {"spread": 6.5}, ttl=3600)
|
||||
|
||||
# Age the stored record past the inferred max_age for odds (1800s) but
|
||||
# within the ttl the caller asked for.
|
||||
path = cm._disk_cache_component.get_cache_path("odds_espn_football_nfl_401")
|
||||
import json
|
||||
rec = json.load(open(path))
|
||||
rec["timestamp"] = time.time() - 1900
|
||||
json.dump(rec, open(path, "w"))
|
||||
cm._memory_cache_component.clear() if hasattr(
|
||||
cm._memory_cache_component, "clear") else None
|
||||
|
||||
got = cm.get_with_auto_strategy("odds_espn_football_nfl_401")
|
||||
assert got is not None, "the ttl the caller asked for was ignored"
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Tests that live content can take extra turns inside the Vegas ticker.
|
||||
|
||||
Vegas was a strict round robin -- every plugin exactly once per cycle -- and
|
||||
live content did not appear in it at all, because the display controller
|
||||
refused to run the ticker while anything was live. With a dozen plugins
|
||||
enabled that left a live score either absent or minutes stale.
|
||||
|
||||
Two things change, both off by default. `live_in_ticker` keeps the marquee
|
||||
running instead of yielding to a full-screen takeover, and the rotation is
|
||||
expanded by Smooth Weighted Round-Robin so a weighted plugin gets several
|
||||
slots per cycle, spaced through it rather than clumped.
|
||||
|
||||
Weights are per plugin, not per game: a scoreboard showing four live games
|
||||
still occupies one slot at a time and rotates its own games within it.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
|
||||
|
||||
class FakePlugin:
|
||||
"""A plugin that can fail in each place independently.
|
||||
|
||||
hook_raises and live_raises are separate because they mean different
|
||||
things: a broken weight calculation should still leave the core's own
|
||||
live-content check usable, while a plugin that cannot answer whether it is
|
||||
live at all has nothing left to fall back on.
|
||||
"""
|
||||
|
||||
def __init__(self, live=False, declared=None, raises=False,
|
||||
hook_raises=False, live_raises=False):
|
||||
self._live = live
|
||||
self._declared = declared
|
||||
self._hook_raises = hook_raises or raises
|
||||
self._live_raises = live_raises or raises
|
||||
self.enabled = True
|
||||
|
||||
def has_live_priority(self):
|
||||
if self._live_raises:
|
||||
raise RuntimeError("cannot say whether I am live")
|
||||
return self._live
|
||||
|
||||
def has_live_content(self):
|
||||
return self._live
|
||||
|
||||
def get_vegas_priority_weight(self):
|
||||
if self._hook_raises:
|
||||
raise RuntimeError("weight calculation blew up")
|
||||
return self._declared
|
||||
|
||||
|
||||
def _manager(plugins, **cfg):
|
||||
config = VegasModeConfig(live_in_ticker=cfg.pop('live_in_ticker', True), **cfg)
|
||||
pm = Mock()
|
||||
pm.plugins = plugins
|
||||
sm = StreamManager.__new__(StreamManager)
|
||||
sm.config = config
|
||||
sm.plugin_manager = pm
|
||||
return sm
|
||||
|
||||
|
||||
def _counts(schedule):
|
||||
return {p: schedule.count(p) for p in set(schedule)}
|
||||
|
||||
|
||||
def _max_gap(schedule, plugin_id):
|
||||
"""Largest gap between consecutive appearances, wrapping around."""
|
||||
at = [i for i, p in enumerate(schedule) if p == plugin_id]
|
||||
if len(at) < 2:
|
||||
return len(schedule)
|
||||
gaps = [b - a for a, b in zip(at, at[1:])]
|
||||
gaps.append(len(schedule) - at[-1] + at[0])
|
||||
return max(gaps)
|
||||
|
||||
|
||||
class TestWeightsComeFromTheRightPlace:
|
||||
def test_a_quiet_plugin_gets_one_slot(self):
|
||||
sm = _manager({'clock': FakePlugin()})
|
||||
assert sm._plugin_weight('clock') == 1
|
||||
|
||||
def test_live_content_earns_the_configured_weight(self):
|
||||
sm = _manager({'mlb': FakePlugin(live=True)}, live_weight=4)
|
||||
assert sm._plugin_weight('mlb') == 4
|
||||
|
||||
def test_a_plugin_may_answer_for_itself(self):
|
||||
# The only route for favorite-team awareness: the core can see that a
|
||||
# game is live, not whose.
|
||||
sm = _manager({'mlb': FakePlugin(live=True, declared=7)}, live_weight=3)
|
||||
assert sm._plugin_weight('mlb') == 7
|
||||
|
||||
def test_declaring_none_defers_to_the_core(self):
|
||||
sm = _manager({'mlb': FakePlugin(live=True, declared=None)}, live_weight=3)
|
||||
assert sm._plugin_weight('mlb') == 3
|
||||
|
||||
def test_a_declared_weight_is_clamped(self):
|
||||
sm = _manager({'a': FakePlugin(declared=99), 'b': FakePlugin(declared=0)})
|
||||
assert sm._plugin_weight('a') == 10
|
||||
assert sm._plugin_weight('b') == 1
|
||||
|
||||
def test_a_plugin_that_raises_everywhere_weighs_one(self):
|
||||
sm = _manager({'bad': FakePlugin(raises=True)})
|
||||
assert sm._plugin_weight('bad') == 1
|
||||
|
||||
def test_a_broken_hook_still_earns_the_live_boost(self):
|
||||
# The hook is only how a plugin asks for *more* than live_weight.
|
||||
# Losing it should cost the favorite distinction, not the live boost:
|
||||
# has_live_priority/has_live_content are separate and still work.
|
||||
sm = _manager({'mlb': FakePlugin(live=True, hook_raises=True)},
|
||||
live_weight=4)
|
||||
assert sm._plugin_weight('mlb') == 4
|
||||
|
||||
def test_a_broken_hook_on_a_quiet_plugin_weighs_one(self):
|
||||
sm = _manager({'clock': FakePlugin(live=False, hook_raises=True)},
|
||||
live_weight=4)
|
||||
assert sm._plugin_weight('clock') == 1
|
||||
|
||||
def test_a_plugin_that_cannot_say_whether_it_is_live_weighs_one(self):
|
||||
# Nothing left to fall back on, so no boost.
|
||||
sm = _manager({'mlb': FakePlugin(live=True, live_raises=True)},
|
||||
live_weight=4)
|
||||
assert sm._plugin_weight('mlb') == 1
|
||||
|
||||
def test_an_unknown_plugin_weighs_one(self):
|
||||
assert _manager({})._plugin_weight('ghost') == 1
|
||||
|
||||
|
||||
class TestTheSchedule:
|
||||
def test_nothing_weighted_leaves_the_order_untouched(self):
|
||||
order = ['weather', 'clock', 'news']
|
||||
sm = _manager({p: FakePlugin() for p in order})
|
||||
assert sm._apply_priority_weights(order) == order
|
||||
|
||||
def test_off_by_default_the_order_is_untouched(self):
|
||||
order = ['weather', 'mlb', 'news']
|
||||
sm = _manager({'weather': FakePlugin(), 'mlb': FakePlugin(live=True),
|
||||
'news': FakePlugin()}, live_in_ticker=False, live_weight=3)
|
||||
assert sm._apply_priority_weights(order) == order
|
||||
|
||||
def test_a_live_plugin_takes_its_share_of_slots(self):
|
||||
order = ['weather', 'mlb', 'news', 'clock']
|
||||
sm = _manager({'weather': FakePlugin(), 'mlb': FakePlugin(live=True),
|
||||
'news': FakePlugin(), 'clock': FakePlugin()},
|
||||
live_weight=3)
|
||||
schedule = sm._apply_priority_weights(order)
|
||||
counts = _counts(schedule)
|
||||
assert counts['mlb'] == 3, counts
|
||||
assert counts['weather'] == counts['news'] == counts['clock'] == 1, counts
|
||||
assert len(schedule) == 6
|
||||
|
||||
def test_every_plugin_still_appears(self):
|
||||
# A boost must not starve anything out of the cycle.
|
||||
order = ['a', 'b', 'c', 'd', 'e', 'f']
|
||||
plugins = {p: FakePlugin() for p in order}
|
||||
plugins['a'] = FakePlugin(live=True, declared=10)
|
||||
sm = _manager(plugins)
|
||||
schedule = sm._apply_priority_weights(order)
|
||||
assert set(schedule) == set(order), set(order) - set(schedule)
|
||||
|
||||
def test_nothing_doubles_across_the_cycle_seam(self):
|
||||
# The strip loops, so the last slot neighbours the first. Smooth
|
||||
# Weighted Round-Robin schedules the heaviest item first and often
|
||||
# last too, which put the one clump the algorithm exists to avoid at
|
||||
# the one place a within-cycle check cannot see.
|
||||
order = ['baseball', 'weather', 'geochron', 'flights', 'stocks',
|
||||
'oftheday', 'youtube', 'stocknews', 'leaderboard',
|
||||
'countdown', 'odds', 'f1', 'football', 'music']
|
||||
plugins = {p: FakePlugin() for p in order}
|
||||
plugins['baseball'] = FakePlugin(live=True, declared=5)
|
||||
plugins['football'] = FakePlugin(live=True, declared=3)
|
||||
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||
|
||||
n = len(schedule)
|
||||
doubles = [schedule[i] for i in range(n)
|
||||
if schedule[i] == schedule[(i + 1) % n]]
|
||||
assert not doubles, "%r repeats across the seam in %r" % (doubles, schedule)
|
||||
|
||||
def test_the_seam_repair_keeps_every_slot(self):
|
||||
order = ['a', 'b', 'c', 'd', 'e', 'f']
|
||||
plugins = {p: FakePlugin() for p in order}
|
||||
plugins['a'] = FakePlugin(live=True, declared=4)
|
||||
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||
assert _counts(schedule)['a'] == 4, _counts(schedule)
|
||||
assert sorted(schedule) == sorted(
|
||||
['a'] * 4 + ['b', 'c', 'd', 'e', 'f']), schedule
|
||||
|
||||
def test_the_repair_uses_the_widest_gap(self):
|
||||
# Moving the trailing repeat into the first slot that merely fits
|
||||
# undoes the spacing: on a 28-slot rotation that turned a gap of 7
|
||||
# into a gap of 2, which is more clumped than the seam ever was.
|
||||
order = ['a'] + ['p%d' % i for i in range(13)]
|
||||
plugins = {p: FakePlugin() for p in order}
|
||||
plugins['a'] = FakePlugin(live=True, declared=4)
|
||||
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||
at = [i for i, p in enumerate(schedule) if p == 'a']
|
||||
gaps = [b - a for a, b in zip(at, at[1:])]
|
||||
gaps.append(len(schedule) - at[-1] + at[0])
|
||||
ideal = len(schedule) / len(at)
|
||||
assert min(gaps) >= ideal / 2, "gaps %r for ideal %.1f" % (gaps, ideal)
|
||||
|
||||
def test_an_unavoidable_double_is_left_alone(self):
|
||||
# Five of seven slots are the same plugin, so it must neighbour
|
||||
# itself. Better to schedule it than to refuse or loop forever.
|
||||
order = ['a', 'b', 'c']
|
||||
plugins = {p: FakePlugin() for p in order}
|
||||
plugins['a'] = FakePlugin(live=True, declared=5)
|
||||
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||
assert _counts(schedule) == {'a': 5, 'b': 1, 'c': 1}, _counts(schedule)
|
||||
assert set(schedule) == {'a', 'b', 'c'}
|
||||
|
||||
def test_a_schedule_too_short_to_repair_is_returned_as_is(self):
|
||||
sm = _manager({})
|
||||
assert sm._unclump_seam(['a', 'a']) == ['a', 'a']
|
||||
assert sm._unclump_seam(['a']) == ['a']
|
||||
assert sm._unclump_seam([]) == []
|
||||
|
||||
def test_a_schedule_with_no_seam_clash_is_untouched(self):
|
||||
sm = _manager({})
|
||||
plain = ['a', 'b', 'c', 'a', 'd']
|
||||
assert sm._unclump_seam(plain) == plain
|
||||
|
||||
def test_repeats_are_spread_not_clumped(self):
|
||||
# The point of Smooth Weighted Round-Robin. Three-in-a-row followed by
|
||||
# a long silence would be worse than not boosting at all.
|
||||
order = ['weather', 'mlb', 'news', 'clock', 'stocks', 'f1']
|
||||
plugins = {p: FakePlugin() for p in order}
|
||||
plugins['mlb'] = FakePlugin(live=True)
|
||||
sm = _manager(plugins, live_weight=3)
|
||||
schedule = sm._apply_priority_weights(order)
|
||||
|
||||
assert _counts(schedule)['mlb'] == 3
|
||||
# Evenly spread over 8 slots means a gap of about 3, never 6.
|
||||
assert _max_gap(schedule, 'mlb') <= 4, schedule
|
||||
# And never twice running.
|
||||
assert not any(a == b == 'mlb' for a, b in zip(schedule, schedule[1:])), schedule
|
||||
|
||||
def test_a_favorite_outranks_another_live_game(self):
|
||||
order = ['weather', 'mlb', 'nhl']
|
||||
sm = _manager({'weather': FakePlugin(),
|
||||
'mlb': FakePlugin(live=True, declared=5),
|
||||
'nhl': FakePlugin(live=True)}, live_weight=2)
|
||||
counts = _counts(sm._apply_priority_weights(order))
|
||||
assert counts['mlb'] == 5 and counts['nhl'] == 2 and counts['weather'] == 1, counts
|
||||
|
||||
def test_an_empty_rotation_is_harmless(self):
|
||||
assert _manager({})._apply_priority_weights([]) == []
|
||||
|
||||
|
||||
class TestConfigParsing:
|
||||
def test_defaults_preserve_todays_behaviour(self):
|
||||
cfg = VegasModeConfig.from_config({})
|
||||
assert cfg.live_in_ticker is False
|
||||
assert cfg.live_weight == 3 and cfg.favorite_live_weight == 5
|
||||
|
||||
@pytest.mark.parametrize("given,expected", [(0, 1), (-4, 1), (99, 10), (4, 4)])
|
||||
def test_weights_are_clamped(self, given, expected):
|
||||
cfg = VegasModeConfig.from_config(
|
||||
{'display': {'vegas_scroll': {'live_weight': given}}})
|
||||
assert cfg.live_weight == expected
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Tests for surfacing the underlying error in web responses.
|
||||
|
||||
Regression under test: every failing endpoint returned "An error occurred; see
|
||||
logs for details" and nothing else. On a device whose storage was failing that
|
||||
sentence came back from the restart action, from /system/status, and from
|
||||
/logs -- the log viewer itself -- because journalctl could not be executed. The
|
||||
exception underneath said `[Errno 5] Input/output error: 'systemctl'`, which
|
||||
names the fault outright, and nine handlers were discarding it entirely rather
|
||||
than even logging it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.web_interface.error_handler import describe_exception
|
||||
|
||||
|
||||
class TestDescribeException:
|
||||
def test_names_the_type_and_message(self):
|
||||
detail = describe_exception(OSError(5, "Input/output error", "systemctl"))
|
||||
assert detail == "OSError: [Errno 5] Input/output error: 'systemctl'"
|
||||
|
||||
def test_the_reported_failure_is_legible(self):
|
||||
# The whole point: this string is the diagnosis.
|
||||
assert "Input/output error" in describe_exception(
|
||||
OSError(5, "Input/output error", "systemctl"))
|
||||
|
||||
def test_a_bare_exception_still_names_its_type(self):
|
||||
# A PermissionError with no message still says more than "unknown".
|
||||
assert describe_exception(PermissionError()) == "PermissionError"
|
||||
assert describe_exception(Exception()) == "Exception"
|
||||
|
||||
def test_message_is_kept_when_present(self):
|
||||
assert describe_exception(ValueError("bad port")) == "ValueError: bad port"
|
||||
|
||||
|
||||
class TestCredentialRedaction:
|
||||
"""Exception text quotes URLs, and plugins authenticate by query string."""
|
||||
|
||||
@pytest.mark.parametrize("secret_text,leaked", [
|
||||
("failed: https://api.x.com/v1?api_key=SEC123&city=Tampa", "SEC123"),
|
||||
("token=abcdef123456 was rejected", "abcdef123456"),
|
||||
("connect failed password=hunter2", "hunter2"),
|
||||
("GET /?access_token=zzz999", "zzz999"),
|
||||
('{"secret": "topsecret"}', "topsecret"),
|
||||
# requests quotes the URL it failed on, and both of these forms turn
|
||||
# up in real client exceptions.
|
||||
("401 for https://user:hunter2@example.com/api", "hunter2"),
|
||||
("headers: {'Authorization': 'Bearer eyJ.SECRET.sig'}", "eyJ.SECRET.sig"),
|
||||
("Authorization: Basic dXNlcjpwYXNzd29yZA==", "dXNlcjpwYXNzd29yZA=="),
|
||||
("Proxy-Authorization: Bearer ptok999", "ptok999"),
|
||||
# Any scheme, not a fixed list -- a list silently leaks whatever it
|
||||
# does not name, and plugin APIs invent their own.
|
||||
("Authorization: ApiKey SECRET123", "SECRET123"),
|
||||
("Authorization: Negotiate YIIZnegotiateblob", "YIIZnegotiateblob"),
|
||||
("Authorization: NTLM TlRMTVNTUAAB", "TlRMTVNTUAAB"),
|
||||
("authorization: barecredential", "barecredential"),
|
||||
])
|
||||
def test_credentials_never_reach_the_response(self, secret_text, leaked):
|
||||
detail = describe_exception(RuntimeError(secret_text))
|
||||
assert leaked not in detail
|
||||
assert "<redacted>" in detail
|
||||
|
||||
def test_the_parameter_name_survives_redaction(self):
|
||||
# Knowing *which* credential was involved is part of the diagnosis.
|
||||
detail = describe_exception(RuntimeError("https://x/y?api_key=SEC123"))
|
||||
assert "api_key" in detail
|
||||
|
||||
def test_unknown_schemes_keep_their_name(self):
|
||||
for scheme in ("ApiKey", "Negotiate", "NTLM", "AWS4-HMAC-SHA256"):
|
||||
detail = describe_exception(
|
||||
RuntimeError("Authorization: %s SECRETVALUE" % scheme))
|
||||
assert scheme in detail, detail
|
||||
assert "SECRETVALUE" not in detail, detail
|
||||
|
||||
def test_auth_scheme_and_username_survive(self):
|
||||
# Which kind of credential, and whose, without the credential itself.
|
||||
assert "Bearer" in describe_exception(
|
||||
RuntimeError("Authorization: Bearer eyJ.SECRET.sig"))
|
||||
assert "user" in describe_exception(
|
||||
RuntimeError("https://user:hunter2@example.com"))
|
||||
|
||||
def test_non_secret_context_is_preserved(self):
|
||||
detail = describe_exception(RuntimeError("https://api.x.com/v1?city=Tampa"))
|
||||
assert "city=Tampa" in detail
|
||||
assert "<redacted>" not in detail
|
||||
|
||||
|
||||
class TestBounds:
|
||||
def test_long_messages_are_truncated(self):
|
||||
detail = describe_exception(ValueError("x" * 5000))
|
||||
assert len(detail) <= 400
|
||||
|
||||
def test_newlines_are_collapsed_to_one_line(self):
|
||||
detail = describe_exception(ValueError("line one\nline two\tthree"))
|
||||
assert "\n" not in detail and "\t" not in detail
|
||||
assert detail == "ValueError: line one line two three"
|
||||
|
||||
def test_custom_length_is_honoured(self):
|
||||
assert len(describe_exception(ValueError("y" * 500), max_length=50)) <= 50
|
||||
|
||||
|
||||
class TestHandlersCarryDetail:
|
||||
"""The response shape callers actually see."""
|
||||
|
||||
def test_no_api_v3_handler_discards_its_exception(self):
|
||||
"""Every generic-message handler must log a traceback and return detail.
|
||||
|
||||
Nine of them bound `e` and never used it, so the promised log entry was
|
||||
never written either. Checking merely that *something* was logged is
|
||||
too weak -- a `logger.info("failed")` would satisfy it while throwing
|
||||
the exception away just as completely, so this asserts the two things
|
||||
that actually make the failure diagnosable: an error-level record with
|
||||
the traceback, and the sanitized detail in the response.
|
||||
"""
|
||||
import ast
|
||||
|
||||
src = open("web_interface/blueprints/api_v3.py").read()
|
||||
tree = ast.parse(src)
|
||||
generic = "An error occurred; see logs for details"
|
||||
|
||||
def logs_a_traceback(handler):
|
||||
"""An error/exception-level log call carrying exc_info."""
|
||||
for call in [n for n in ast.walk(handler) if isinstance(n, ast.Call)]:
|
||||
func = call.func
|
||||
if not isinstance(func, ast.Attribute):
|
||||
continue
|
||||
if func.attr == "exception": # implies exc_info
|
||||
return True
|
||||
if func.attr not in ("error", "critical"):
|
||||
continue
|
||||
if any(kw.arg == "exc_info" and getattr(kw.value, "value", False) is True
|
||||
for kw in call.keywords):
|
||||
return True
|
||||
return False
|
||||
|
||||
def describes_this_exception(node, bound):
|
||||
"""A describe_exception(<bound>) call anywhere under `node`."""
|
||||
for call in [n for n in ast.walk(node) if isinstance(n, ast.Call)]:
|
||||
if not (isinstance(call.func, ast.Name)
|
||||
and call.func.id == "describe_exception"):
|
||||
continue
|
||||
if bound is None:
|
||||
return True # bare `except:` cannot name it; accept
|
||||
if any(isinstance(a, ast.Name) and a.id == bound
|
||||
for a in call.args):
|
||||
return True
|
||||
return False
|
||||
|
||||
def returns_the_detail(handler):
|
||||
"""The detail must be inside what the handler actually returns.
|
||||
|
||||
Looking anywhere in the handler is too weak: a handler could
|
||||
compute describe_exception(e), drop it on the floor, and return the
|
||||
generic message with no details field, while still passing. So the
|
||||
call has to appear within a `return` expression.
|
||||
"""
|
||||
returns = [n for n in ast.walk(handler) if isinstance(n, ast.Return)]
|
||||
if not returns:
|
||||
return False
|
||||
return all(describes_this_exception(r, handler.name) for r in returns)
|
||||
|
||||
offenders = []
|
||||
for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]:
|
||||
seg = ast.get_source_segment(src, h) or ""
|
||||
if generic not in seg:
|
||||
continue
|
||||
missing = []
|
||||
if not logs_a_traceback(h):
|
||||
missing.append("error-level log with exc_info")
|
||||
if not returns_the_detail(h):
|
||||
missing.append("describe_exception(e) in the response")
|
||||
if missing:
|
||||
offenders.append((h.lineno, missing))
|
||||
|
||||
assert not offenders, (
|
||||
"handlers returning the generic message without %s: %r"
|
||||
% ("both a traceback log and the detail", offenders))
|
||||
|
||||
def test_client_errors_keep_their_own_status(self):
|
||||
"""A 405 must not be reported as a server-side UNKNOWN_ERROR.
|
||||
|
||||
Werkzeug's HTTPExceptions subclass Exception, so the catch-all saw them
|
||||
too: a GET on a POST-only route came back 500 "an error occurred",
|
||||
which tells the caller nothing and blames the wrong side. Found while
|
||||
probing a device whose POST-only config endpoints answered every GET
|
||||
with UNKNOWN_ERROR.
|
||||
"""
|
||||
from flask import Flask, jsonify
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle(error):
|
||||
if isinstance(error, HTTPException):
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error_code": (error.name or "HTTP_ERROR").upper().replace(" ", "_"),
|
||||
"message": error.description,
|
||||
}), error.code or 500
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error_code": "UNKNOWN_ERROR",
|
||||
"message": "An error occurred; see logs for details",
|
||||
"details": describe_exception(error),
|
||||
}), 500
|
||||
|
||||
@app.route("/only-post", methods=["POST"])
|
||||
def only_post():
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@app.route("/boom")
|
||||
def boom():
|
||||
raise OSError(5, "Input/output error", "systemctl")
|
||||
|
||||
client = app.test_client()
|
||||
|
||||
resp = client.get("/only-post")
|
||||
assert resp.status_code == 405, "a wrong method must stay a 405"
|
||||
assert resp.get_json()["error_code"] == "METHOD_NOT_ALLOWED"
|
||||
|
||||
# A genuine server fault still reports as one, with its detail.
|
||||
resp = client.get("/boom")
|
||||
assert resp.status_code == 500
|
||||
assert "Input/output error" in resp.get_json()["details"]
|
||||
|
||||
def test_global_handler_reports_the_underlying_error(self):
|
||||
from flask import Flask, jsonify
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle(error):
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"error_code": "UNKNOWN_ERROR",
|
||||
"message": "An error occurred; see logs for details",
|
||||
"details": describe_exception(error),
|
||||
}), 500
|
||||
|
||||
@app.route("/boom")
|
||||
def boom():
|
||||
raise OSError(5, "Input/output error", "systemctl")
|
||||
|
||||
client = app.test_client()
|
||||
body = client.get("/boom").get_json()
|
||||
assert body["error_code"] == "UNKNOWN_ERROR"
|
||||
assert "Input/output error" in body["details"]
|
||||
+33
-3
@@ -16,6 +16,8 @@ from datetime import datetime, timedelta
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.config_manager import ConfigManager
|
||||
from src.web_interface.error_handler import describe_exception
|
||||
from werkzeug.exceptions import HTTPException
|
||||
from src.exceptions import ConfigError
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
@@ -391,15 +393,42 @@ def internal_error(error):
|
||||
import logging
|
||||
logger = logging.getLogger('web_interface')
|
||||
logger.error("Internal server error", exc_info=True)
|
||||
return jsonify({
|
||||
payload = {
|
||||
'status': 'error',
|
||||
'error_code': 'INTERNAL_ERROR',
|
||||
'message': 'An internal error occurred; see logs for details',
|
||||
}), 500
|
||||
}
|
||||
# Flask hands the original exception over as `error.original_exception`
|
||||
# when propagation is off; without it there is nothing to describe.
|
||||
original = getattr(error, 'original_exception', None) or (
|
||||
error if isinstance(error, BaseException) else None)
|
||||
if original is not None:
|
||||
payload['details'] = describe_exception(original)
|
||||
return jsonify(payload), 500
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_exception(error):
|
||||
"""Handle all unhandled exceptions."""
|
||||
"""Handle all unhandled exceptions.
|
||||
|
||||
Returning only "see logs for details" is fine until the logs are exactly
|
||||
what you cannot reach. A device with failing storage answered every
|
||||
endpoint with that sentence -- including the log viewer, because journalctl
|
||||
could not be executed -- while the exception underneath said
|
||||
`[Errno 5] Input/output error`. Naming the error costs nothing here and is
|
||||
frequently the whole diagnosis, so include it alongside the log pointer.
|
||||
"""
|
||||
# Werkzeug's HTTPExceptions subclass Exception, so this catch-all sees
|
||||
# them too and was reporting every 405, 400, 413 and 415 as a server-side
|
||||
# UNKNOWN_ERROR 500. A GET on a POST-only route came back as "an error
|
||||
# occurred" rather than "method not allowed", which tells the caller
|
||||
# nothing and blames the wrong side. Hand those back as themselves.
|
||||
if isinstance(error, HTTPException):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_code': (error.name or 'HTTP_ERROR').upper().replace(' ', '_'),
|
||||
'message': error.description,
|
||||
}), error.code or 500
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger('web_interface')
|
||||
logger.error("Unhandled exception", exc_info=True)
|
||||
@@ -407,6 +436,7 @@ def handle_exception(error):
|
||||
'status': 'error',
|
||||
'error_code': 'UNKNOWN_ERROR',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(error),
|
||||
}), 500
|
||||
|
||||
# Captive portal redirect middleware
|
||||
|
||||
@@ -22,6 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
|
||||
from src.web_interface.errors import ErrorCode
|
||||
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
||||
from src.web_interface.error_handler import describe_exception
|
||||
from src.plugin_system.operation_types import OperationType
|
||||
from src.web_interface.validators import (
|
||||
validate_file_upload
|
||||
@@ -272,7 +273,7 @@ def get_main_config():
|
||||
return jsonify({'status': 'success', 'data': config})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/config/schedule', methods=['GET'])
|
||||
def get_schedule_config():
|
||||
@@ -290,9 +291,11 @@ def get_schedule_config():
|
||||
|
||||
return success_response(data=schedule_config)
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return error_response(
|
||||
ErrorCode.CONFIG_LOAD_FAILED,
|
||||
"An error occurred; see logs for details",
|
||||
details=describe_exception(e),
|
||||
status_code=500
|
||||
)
|
||||
|
||||
@@ -468,7 +471,7 @@ def save_schedule_config():
|
||||
ErrorCode.CONFIG_SAVE_FAILED,
|
||||
"An error occurred; see logs for details",
|
||||
|
||||
status_code=500
|
||||
status_code=500, details=describe_exception(e)
|
||||
)
|
||||
|
||||
@api_v3.route('/config/dim-schedule', methods=['GET'])
|
||||
@@ -516,14 +519,14 @@ def get_dim_schedule_config():
|
||||
return error_response(
|
||||
ErrorCode.CONFIG_LOAD_FAILED,
|
||||
"An error occurred; see logs for details",
|
||||
status_code=500
|
||||
status_code=500, details=describe_exception(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"[DIM SCHEDULE] Unexpected error loading config: {e}", exc_info=True)
|
||||
return error_response(
|
||||
ErrorCode.CONFIG_LOAD_FAILED,
|
||||
"An error occurred; see logs for details",
|
||||
status_code=500
|
||||
status_code=500, details=describe_exception(e)
|
||||
)
|
||||
|
||||
@api_v3.route('/config/dim-schedule', methods=['POST'])
|
||||
@@ -687,7 +690,7 @@ def save_dim_schedule_config():
|
||||
ErrorCode.CONFIG_SAVE_FAILED,
|
||||
"An error occurred; see logs for details",
|
||||
|
||||
status_code=500
|
||||
status_code=500, details=describe_exception(e)
|
||||
)
|
||||
|
||||
@api_v3.route('/config/main', methods=['POST'])
|
||||
@@ -1314,7 +1317,7 @@ def save_main_config():
|
||||
return error_response(
|
||||
ErrorCode.CONFIG_SAVE_FAILED,
|
||||
"An error occurred; see logs for details",
|
||||
status_code=500
|
||||
status_code=500, details=describe_exception(e)
|
||||
)
|
||||
|
||||
@api_v3.route('/config/secrets', methods=['GET'])
|
||||
@@ -1328,7 +1331,7 @@ def get_secrets_config():
|
||||
return jsonify({'status': 'success', 'data': config})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/config/raw/main', methods=['POST'])
|
||||
def save_raw_main_config():
|
||||
@@ -1361,6 +1364,7 @@ def save_raw_main_config():
|
||||
return error_response(
|
||||
ErrorCode.CONFIG_SAVE_FAILED,
|
||||
error_message,
|
||||
details=describe_exception(e),
|
||||
|
||||
context={'config_path': e.config_path} if hasattr(e, 'config_path') and e.config_path else None,
|
||||
status_code=500
|
||||
@@ -1370,6 +1374,7 @@ def save_raw_main_config():
|
||||
return error_response(
|
||||
ErrorCode.UNKNOWN_ERROR,
|
||||
error_message,
|
||||
details=describe_exception(e),
|
||||
|
||||
status_code=500
|
||||
)
|
||||
@@ -1409,7 +1414,8 @@ def save_raw_secrets_config():
|
||||
else:
|
||||
error_message = 'An error occurred; see logs for details'
|
||||
|
||||
return jsonify({'status': 'error', 'message': error_message}), 500
|
||||
return jsonify({'status': 'error', 'message': error_message,
|
||||
'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/system/status', methods=['GET'])
|
||||
def get_system_status():
|
||||
@@ -1497,7 +1503,7 @@ def get_system_status():
|
||||
return jsonify({'status': 'success', 'data': status})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/health', methods=['GET'])
|
||||
def get_health():
|
||||
@@ -1596,9 +1602,11 @@ def get_health():
|
||||
|
||||
return jsonify({'status': 'success', 'data': health_status})
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e),
|
||||
'data': {'status': 'unhealthy'}
|
||||
}), 500
|
||||
|
||||
@@ -2368,7 +2376,7 @@ def get_display_current():
|
||||
return jsonify({'status': 'success', 'data': display_data})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/display/on-demand/status', methods=['GET'])
|
||||
def get_on_demand_status():
|
||||
@@ -2392,7 +2400,7 @@ def get_on_demand_status():
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error('Error in get_on_demand_status', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||
|
||||
@api_v3.route('/display/on-demand/start', methods=['POST'])
|
||||
def start_on_demand_display():
|
||||
@@ -2495,7 +2503,7 @@ def start_on_demand_display():
|
||||
return jsonify({'status': 'success', 'data': response_data})
|
||||
except Exception as exc:
|
||||
logger.error('Error in start_on_demand_display', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||
|
||||
@api_v3.route('/display/on-demand/stop', methods=['POST'])
|
||||
def stop_on_demand_display():
|
||||
@@ -2531,7 +2539,7 @@ def stop_on_demand_display():
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error('Error in stop_on_demand_display', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||
|
||||
@api_v3.route('/plugins/installed', methods=['GET'])
|
||||
def get_installed_plugins():
|
||||
@@ -2679,7 +2687,7 @@ def get_installed_plugins():
|
||||
return jsonify({'status': 'success', 'data': {'plugins': plugins}})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_installed_plugins', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
def _installed_plugin_ids():
|
||||
"""Best-effort list of installed plugin IDs for the web process.
|
||||
@@ -2745,7 +2753,7 @@ def get_plugin_health():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_plugin_health', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/health/<plugin_id>', methods=['GET'])
|
||||
def get_plugin_health_single(plugin_id):
|
||||
@@ -2770,7 +2778,7 @@ def get_plugin_health_single(plugin_id):
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_plugin_health_single', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/health/<plugin_id>/reset', methods=['POST'])
|
||||
def reset_plugin_health(plugin_id):
|
||||
@@ -2795,7 +2803,7 @@ def reset_plugin_health(plugin_id):
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in reset_plugin_health', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/metrics', methods=['GET'])
|
||||
def get_plugin_metrics():
|
||||
@@ -2835,7 +2843,7 @@ def get_plugin_metrics():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_plugin_metrics', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/metrics/<plugin_id>', methods=['GET'])
|
||||
def get_plugin_metrics_single(plugin_id):
|
||||
@@ -2860,7 +2868,7 @@ def get_plugin_metrics_single(plugin_id):
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_plugin_metrics_single', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/metrics/<plugin_id>/reset', methods=['POST'])
|
||||
def reset_plugin_metrics(plugin_id):
|
||||
@@ -2885,7 +2893,7 @@ def reset_plugin_metrics(plugin_id):
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in reset_plugin_metrics', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/limits/<plugin_id>', methods=['GET', 'POST'])
|
||||
def manage_plugin_limits(plugin_id):
|
||||
@@ -2940,7 +2948,7 @@ def manage_plugin_limits(plugin_id):
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in manage_plugin_limits', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/toggle', methods=['POST'])
|
||||
def toggle_plugin():
|
||||
@@ -3949,7 +3957,7 @@ def install_plugin():
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error in install_plugin', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/install-from-url', methods=['POST'])
|
||||
def install_plugin_from_url():
|
||||
@@ -4004,7 +4012,7 @@ def install_plugin_from_url():
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error in install_plugin_from_url', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/registry-from-url', methods=['POST'])
|
||||
def get_registry_from_url():
|
||||
@@ -4036,7 +4044,7 @@ def get_registry_from_url():
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error in get_registry_from_url', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/saved-repositories', methods=['GET'])
|
||||
def get_saved_repositories():
|
||||
@@ -4049,7 +4057,7 @@ def get_saved_repositories():
|
||||
return jsonify({'status': 'success', 'data': {'repositories': repositories}})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_saved_repositories', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/saved-repositories', methods=['POST'])
|
||||
def add_saved_repository():
|
||||
@@ -4080,7 +4088,7 @@ def add_saved_repository():
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error('Error in add_saved_repository', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/saved-repositories', methods=['DELETE'])
|
||||
def remove_saved_repository():
|
||||
@@ -4110,7 +4118,7 @@ def remove_saved_repository():
|
||||
}), 404
|
||||
except Exception as e:
|
||||
logger.error('Error in remove_saved_repository', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/store/list', methods=['GET'])
|
||||
def list_plugin_store():
|
||||
@@ -4163,7 +4171,7 @@ def list_plugin_store():
|
||||
return jsonify({'status': 'success', 'data': {'plugins': formatted_plugins}})
|
||||
except Exception as e:
|
||||
logger.error('Error in list_plugin_store', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/store/github-status', methods=['GET'])
|
||||
def get_github_auth_status():
|
||||
@@ -4214,7 +4222,7 @@ def get_github_auth_status():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_github_auth_status', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/store/refresh', methods=['POST'])
|
||||
def refresh_plugin_store():
|
||||
@@ -4241,7 +4249,7 @@ def refresh_plugin_store():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in refresh_plugin_store', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
def deep_merge(base_dict, update_dict):
|
||||
"""
|
||||
@@ -5763,7 +5771,7 @@ def get_plugin_schema():
|
||||
return jsonify({'status': 'success', 'data': {'schema': default_schema}})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_plugin_schema', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/skins', methods=['GET'])
|
||||
def list_skins():
|
||||
@@ -5798,9 +5806,9 @@ def list_skins():
|
||||
'has_preview': bool(preview and (skin_dir / preview).is_file()),
|
||||
})
|
||||
return jsonify({'status': 'success', 'data': {'skins': payload}})
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.error('Error in list_skins', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/config/reset', methods=['POST'])
|
||||
def reset_plugin_config():
|
||||
@@ -5880,7 +5888,7 @@ def reset_plugin_config():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in reset_plugin_config', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/action', methods=['POST'])
|
||||
def execute_plugin_action():
|
||||
@@ -6140,7 +6148,7 @@ sys.exit(proc.returncode)
|
||||
logger.error("Error executing action step 1", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
else:
|
||||
# Simple script execution
|
||||
@@ -6190,7 +6198,7 @@ sys.exit(proc.returncode)
|
||||
return jsonify({'status': 'error', 'message': 'Action timed out'}), 408
|
||||
except Exception as e:
|
||||
logger.error('Error in execute_plugin_action', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/authenticate/spotify', methods=['POST'])
|
||||
def authenticate_spotify():
|
||||
@@ -6323,12 +6331,12 @@ sys.exit(proc.returncode)
|
||||
logger.error("Error getting Spotify auth URL", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error in authenticate_spotify', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/authenticate/ytm', methods=['POST'])
|
||||
def authenticate_ytm():
|
||||
@@ -6378,7 +6386,7 @@ def authenticate_ytm():
|
||||
return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408
|
||||
except Exception as e:
|
||||
logger.error('Error in authenticate_ytm', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/fonts/catalog', methods=['GET'])
|
||||
def get_fonts_catalog():
|
||||
@@ -6473,7 +6481,10 @@ def get_fonts_catalog():
|
||||
|
||||
return jsonify({'status': 'success', 'data': {'catalog': catalog}})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/fonts/tokens', methods=['GET'])
|
||||
def get_font_tokens():
|
||||
@@ -6492,7 +6503,7 @@ def get_font_tokens():
|
||||
return jsonify({'status': 'success', 'data': {'tokens': tokens}})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/fonts/overrides', methods=['GET'])
|
||||
def get_fonts_overrides():
|
||||
@@ -6504,7 +6515,7 @@ def get_fonts_overrides():
|
||||
return jsonify({'status': 'success', 'data': {'overrides': overrides}})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/fonts/overrides', methods=['POST'])
|
||||
def save_fonts_overrides():
|
||||
@@ -6518,7 +6529,7 @@ def save_fonts_overrides():
|
||||
return jsonify({'status': 'success', 'message': 'Font overrides saved'})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/fonts/overrides/<element_key>', methods=['DELETE'])
|
||||
def delete_font_override(element_key):
|
||||
@@ -6528,7 +6539,7 @@ def delete_font_override(element_key):
|
||||
return jsonify({'status': 'success', 'message': f'Font override for {element_key} deleted'})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/fonts/upload', methods=['POST'])
|
||||
def upload_font():
|
||||
@@ -6593,7 +6604,7 @@ def upload_font():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
|
||||
@api_v3.route('/fonts/preview', methods=['GET'])
|
||||
@@ -6738,7 +6749,7 @@ def get_font_preview() -> tuple[Response, int] | Response:
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
|
||||
@api_v3.route('/fonts/<font_family>', methods=['DELETE'])
|
||||
@@ -6826,7 +6837,7 @@ def delete_font(font_family: str) -> tuple[Response, int] | Response:
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
|
||||
@api_v3.route('/plugins/assets/upload', methods=['POST'])
|
||||
@@ -6974,7 +6985,7 @@ def upload_plugin_asset():
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/of-the-day/json/upload', methods=['POST'])
|
||||
def upload_of_the_day_json():
|
||||
@@ -7124,7 +7135,7 @@ def upload_of_the_day_json():
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/of-the-day/json/delete', methods=['POST'])
|
||||
def delete_of_the_day_json():
|
||||
@@ -7171,7 +7182,7 @@ def delete_of_the_day_json():
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/<plugin_id>/static/<path:file_path>', methods=['GET'])
|
||||
def serve_plugin_static(plugin_id, file_path):
|
||||
@@ -7217,7 +7228,7 @@ def serve_plugin_static(plugin_id, file_path):
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
|
||||
@api_v3.route('/plugins/calendar/upload-credentials', methods=['POST'])
|
||||
@@ -7299,7 +7310,7 @@ def upload_calendar_credentials():
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error in upload_calendar_credentials', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/assets/delete', methods=['POST'])
|
||||
def delete_plugin_asset():
|
||||
@@ -7342,7 +7353,7 @@ def delete_plugin_asset():
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/plugins/assets/list', methods=['GET'])
|
||||
def list_plugin_assets():
|
||||
@@ -7370,7 +7381,7 @@ def list_plugin_assets():
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/display/current-status', methods=['GET'])
|
||||
def get_current_display_status():
|
||||
@@ -7391,9 +7402,9 @@ def get_current_display_status():
|
||||
'last_updated': None,
|
||||
}
|
||||
return jsonify({'status': 'success', 'data': state})
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.error('Error in get_current_display_status', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/logs', methods=['GET'])
|
||||
def get_logs():
|
||||
@@ -7432,9 +7443,11 @@ def get_logs():
|
||||
'message': 'Timeout while fetching logs'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
|
||||
# Multi-Display Sync Endpoints
|
||||
@@ -7499,9 +7512,11 @@ def get_wifi_status():
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/wifi/scan', methods=['GET'])
|
||||
@@ -7623,7 +7638,7 @@ def connect_wifi():
|
||||
logger.error("Error connecting to WiFi", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/wifi/disconnect', methods=['POST'])
|
||||
@@ -7649,7 +7664,7 @@ def disconnect_wifi():
|
||||
logger.error("Error disconnecting from WiFi", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/wifi/ap/enable', methods=['POST'])
|
||||
@@ -7674,9 +7689,11 @@ def enable_ap_mode():
|
||||
'message': message
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/wifi/ap/disable', methods=['POST'])
|
||||
@@ -7699,9 +7716,11 @@ def disable_ap_mode():
|
||||
'message': message
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/wifi/ap/auto-enable', methods=['GET'])
|
||||
@@ -7720,9 +7739,11 @@ def get_auto_enable_ap_mode():
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/wifi/ap/auto-enable', methods=['POST'])
|
||||
@@ -7752,9 +7773,11 @@ def set_auto_enable_ap_mode():
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/wifi/radio', methods=['GET'])
|
||||
@@ -7774,7 +7797,7 @@ def get_wifi_radio():
|
||||
logger.error("Error getting WiFi radio state", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/wifi/radio', methods=['POST'])
|
||||
@@ -7822,7 +7845,7 @@ def set_wifi_radio():
|
||||
logger.error("Error setting WiFi radio state", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details'
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
|
||||
@api_v3.route('/cache/list', methods=['GET'])
|
||||
@@ -7847,7 +7870,7 @@ def list_cache_files():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in list_cache_files', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/cache/delete', methods=['POST'])
|
||||
def delete_cache_file():
|
||||
@@ -7873,7 +7896,7 @@ def delete_cache_file():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in delete_cache_file', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user