mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-13 14:48:06 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
838f0b9d8a | ||
|
|
1b9ecc0f19 | ||
|
|
7685e94ca5 |
@@ -129,9 +129,6 @@
|
|||||||
"plugin_rotation_order": [],
|
"plugin_rotation_order": [],
|
||||||
"use_short_date_format": true,
|
"use_short_date_format": true,
|
||||||
"vegas_scroll": {
|
"vegas_scroll": {
|
||||||
"live_in_ticker": false,
|
|
||||||
"live_weight": 3,
|
|
||||||
"favorite_live_weight": 5,
|
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"scroll_speed": 50,
|
"scroll_speed": 50,
|
||||||
"separator_width": 32,
|
"separator_width": 32,
|
||||||
|
|||||||
@@ -64,98 +64,10 @@ JSON is optional.
|
|||||||
| `target_fps` | `125` | Target frame rate |
|
| `target_fps` | `125` | Target frame rate |
|
||||||
| `buffer_ahead` | `2` | Number of plugins buffered ahead |
|
| `buffer_ahead` | `2` | Number of plugins buffered ahead |
|
||||||
|
|
||||||
This table is a subset — `display.vegas_scroll` supports 30 keys in
|
This table is a subset — `display.vegas_scroll` supports 26 keys in
|
||||||
total. See the full list in
|
total. See the full list in
|
||||||
[CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#displayvegas_scroll--continuous-scroll-mode).
|
[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
|
### Per-Plugin Configuration
|
||||||
|
|
||||||
Override Vegas behavior for specific plugins:
|
Override Vegas behavior for specific plugins:
|
||||||
|
|||||||
@@ -103,8 +103,7 @@ logical image to multiple chained physical panels.
|
|||||||
## `display.vegas_scroll` — continuous scroll mode
|
## `display.vegas_scroll` — continuous scroll mode
|
||||||
|
|
||||||
Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details, including
|
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details.
|
||||||
[live content in the ticker](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
|
||||||
|
|
||||||
| Key | Type / default |
|
| Key | Type / default |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -135,9 +134,6 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
|||||||
| `max_cycle_duration` | int, `240` |
|
| `max_cycle_duration` | int, `240` |
|
||||||
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
|
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
|
||||||
| `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) |
|
| `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
|
## `sync` — multi-display synchronization
|
||||||
|
|
||||||
|
|||||||
@@ -170,47 +170,6 @@ Default returns `False`.
|
|||||||
List of display modes to show during a live takeover. Default returns the
|
List of display modes to show during a live takeover. Default returns the
|
||||||
plugin's `display_modes` from its manifest.
|
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 scroll hooks
|
||||||
|
|
||||||
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
||||||
|
|||||||
@@ -1638,12 +1638,6 @@ class DisplayController:
|
|||||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||||
return live
|
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):
|
def _check_live_priority(self, advance=False):
|
||||||
"""Return the live-priority mode to display, or None if nothing is live.
|
"""Return the live-priority mode to display, or None if nothing is live.
|
||||||
|
|
||||||
@@ -1857,24 +1851,14 @@ class DisplayController:
|
|||||||
# Check for live priority content and switch to it immediately.
|
# Check for live priority content and switch to it immediately.
|
||||||
# advance=True so multiple simultaneously-live games take turns
|
# advance=True so multiple simultaneously-live games take turns
|
||||||
# (round-robin) instead of pinning to the first plugin.
|
# (round-robin) instead of pinning to the first plugin.
|
||||||
# Skipped when the ticker is keeping live content: switching
|
if not self.on_demand_active and not wifi_status_data:
|
||||||
# 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)
|
live_priority_mode = self._check_live_priority(advance=True)
|
||||||
self._apply_live_priority(live_priority_mode)
|
self._apply_live_priority(live_priority_mode)
|
||||||
|
|
||||||
# Vegas scroll mode - continuous ticker across all plugins
|
# Vegas scroll mode - continuous ticker across all plugins
|
||||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||||
if self._is_vegas_mode_active() and not wifi_status_data:
|
if self._is_vegas_mode_active() and not wifi_status_data:
|
||||||
# Live content normally preempts the ticker entirely. With
|
live_mode = self._check_live_priority()
|
||||||
# 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:
|
if not live_mode:
|
||||||
try:
|
try:
|
||||||
# Run Vegas mode iteration
|
# Run Vegas mode iteration
|
||||||
|
|||||||
@@ -555,48 +555,6 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
return False
|
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]:
|
def get_live_modes(self) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get list of display modes that should be used during live priority takeover.
|
Get list of display modes that should be used during live priority takeover.
|
||||||
|
|||||||
@@ -125,32 +125,6 @@ class VegasModeConfig:
|
|||||||
plugin_order: List[str] = field(default_factory=list)
|
plugin_order: List[str] = field(default_factory=list)
|
||||||
excluded_plugins: Set[str] = field(default_factory=set)
|
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
|
# Performance settings
|
||||||
target_fps: int = 125 # Target frame rate
|
target_fps: int = 125 # Target frame rate
|
||||||
buffer_ahead: int = 2 # Number of plugins to buffer ahead
|
buffer_ahead: int = 2 # Number of plugins to buffer ahead
|
||||||
@@ -201,12 +175,6 @@ class VegasModeConfig:
|
|||||||
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
||||||
plugin_order=list(vegas_config.get('plugin_order', [])),
|
plugin_order=list(vegas_config.get('plugin_order', [])),
|
||||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
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)),
|
target_fps=int(vegas_config.get('target_fps', 125)),
|
||||||
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)),
|
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)),
|
||||||
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True),
|
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True),
|
||||||
@@ -236,9 +204,6 @@ class VegasModeConfig:
|
|||||||
'lead_in_width': self.lead_in_width,
|
'lead_in_width': self.lead_in_width,
|
||||||
'plugins_per_cycle': self.plugins_per_cycle,
|
'plugins_per_cycle': self.plugins_per_cycle,
|
||||||
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
'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,
|
'overflow_mode': self.overflow_mode,
|
||||||
'plugin_order': self.plugin_order,
|
'plugin_order': self.plugin_order,
|
||||||
'excluded_plugins': list(self.excluded_plugins),
|
'excluded_plugins': list(self.excluded_plugins),
|
||||||
@@ -406,15 +371,6 @@ class VegasModeConfig:
|
|||||||
|
|
||||||
if 'enabled' in vegas_config:
|
if 'enabled' in vegas_config:
|
||||||
self.enabled = vegas_config['enabled']
|
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:
|
if 'scroll_speed' in vegas_config:
|
||||||
self.scroll_speed = float(vegas_config['scroll_speed'])
|
self.scroll_speed = float(vegas_config['scroll_speed'])
|
||||||
if 'separator_width' in vegas_config:
|
if 'separator_width' in vegas_config:
|
||||||
|
|||||||
@@ -497,12 +497,6 @@ class VegasModeCoordinator:
|
|||||||
if not self._live_priority_check:
|
if not self._live_priority_check:
|
||||||
return False
|
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:
|
try:
|
||||||
live_mode = self._live_priority_check()
|
live_mode = self._live_priority_check()
|
||||||
if live_mode:
|
if live_mode:
|
||||||
|
|||||||
@@ -406,8 +406,6 @@ class StreamManager:
|
|||||||
)
|
)
|
||||||
logger.info("Ordered plugins: %s", ordered_plugins)
|
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
|
# Atomically update shared state under lock to avoid races with prefetchers
|
||||||
with self._buffer_lock:
|
with self._buffer_lock:
|
||||||
self._ordered_plugins = ordered_plugins
|
self._ordered_plugins = ordered_plugins
|
||||||
@@ -419,130 +417,6 @@ class StreamManager:
|
|||||||
|
|
||||||
logger.info("=" * 60)
|
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:
|
def _prefetch_content(self, count: int = 1) -> None:
|
||||||
"""
|
"""
|
||||||
Prefetch content for upcoming plugins.
|
Prefetch content for upcoming plugins.
|
||||||
|
|||||||
@@ -77,6 +77,25 @@ def describe_exception(exc: BaseException,
|
|||||||
"""
|
"""
|
||||||
message = str(exc).strip()
|
message = str(exc).strip()
|
||||||
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
||||||
|
return redact_text(text, max_length)
|
||||||
|
|
||||||
|
|
||||||
|
def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
||||||
|
"""Make arbitrary text safe to hand back over HTTP.
|
||||||
|
|
||||||
|
Split out of describe_exception because exceptions are not the only thing
|
||||||
|
worth returning: a subprocess's stderr, or a message a helper script
|
||||||
|
printed, is just as useful to a user and just as capable of carrying a
|
||||||
|
token or a password in it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to redact
|
||||||
|
max_length: Truncate beyond this many characters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A single line, credentials replaced, length capped.
|
||||||
|
"""
|
||||||
|
text = text or ''
|
||||||
# Order matters: the URL and header forms are more specific than the
|
# Order matters: the URL and header forms are more specific than the
|
||||||
# generic key=value pattern, which would otherwise chew the scheme.
|
# generic key=value pattern, which would otherwise chew the scheme.
|
||||||
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
|
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
|
||||||
|
|||||||
@@ -1,262 +0,0 @@
|
|||||||
"""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,344 @@
|
|||||||
|
"""Tests the calendar plugin's OAuth and calendar-listing endpoints.
|
||||||
|
|
||||||
|
The plugin's config UI advertised a three-step setup, but only step 1 existed
|
||||||
|
on the server. Step 3's picker fetched /api/v3/plugins/calendar/list-calendars,
|
||||||
|
which was never registered, so Flask fell through to the global 404 handler and
|
||||||
|
the user saw "Resource not found" — with nothing to say which resource. Step 2
|
||||||
|
had no endpoint either, and no field in the schema at all, even though the
|
||||||
|
plugin ships calendar_registration.py written expressly for a web-driven
|
||||||
|
two-step flow.
|
||||||
|
|
||||||
|
These cover the two new routes: that they exist, that they fail with something
|
||||||
|
actionable rather than a bare 404, and that the shapes the widgets consume are
|
||||||
|
what the server actually sends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pickle
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
project_root = Path(__file__).parent.parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from web_interface.blueprints import api_v3 as mod # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch, tmp_path):
|
||||||
|
"""A test client whose calendar plugin lives in tmp_path."""
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
plugin_dir = tmp_path / 'calendar'
|
||||||
|
plugin_dir.mkdir()
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(mod.api_v3, url_prefix='/api/v3')
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
monkeypatch.setattr(mod, '_calendar_plugin_dir', lambda: plugin_dir)
|
||||||
|
with app.test_client() as c:
|
||||||
|
c.plugin_dir = plugin_dir
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def uninstalled(monkeypatch):
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(mod.api_v3, url_prefix='/api/v3')
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
monkeypatch.setattr(mod, '_calendar_plugin_dir', lambda: None)
|
||||||
|
with app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheRoutesExistAtAll:
|
||||||
|
"""The original bug: the URLs the widgets call were not registered."""
|
||||||
|
|
||||||
|
def test_list_calendars_is_routed(self, client):
|
||||||
|
response = client.get('/api/v3/plugins/calendar/list-calendars')
|
||||||
|
# Reaching the handler is the whole point; what it then says about
|
||||||
|
# missing setup is TestItSaysWhatIsWrong's business.
|
||||||
|
assert response.status_code != 404, "still unrouted"
|
||||||
|
assert response.get_json()['message'] != 'Resource not found'
|
||||||
|
|
||||||
|
def test_authenticate_is_routed(self, client):
|
||||||
|
response = client.post('/api/v3/plugins/calendar/authenticate', json={})
|
||||||
|
assert response.status_code != 404, "still unrouted"
|
||||||
|
assert response.get_json()['message'] != 'Resource not found'
|
||||||
|
|
||||||
|
def test_both_urls_match_what_the_widgets_request(self):
|
||||||
|
# The widgets hardcode these; a rename on either side reintroduces the
|
||||||
|
# original bug silently.
|
||||||
|
picker = Path(project_root) / 'web_interface/static/v3/js/widgets/google-calendar-picker.js'
|
||||||
|
oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
|
||||||
|
assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
|
||||||
|
assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
|
||||||
|
source = (Path(project_root) / 'web_interface/blueprints/api_v3.py').read_text(encoding='utf-8')
|
||||||
|
assert "'/plugins/calendar/list-calendars'" in source
|
||||||
|
assert "'/plugins/calendar/authenticate'" in source
|
||||||
|
|
||||||
|
def test_the_oauth_widget_is_dispatched_not_rendered_as_a_text_box(self):
|
||||||
|
# The string branch of the config template dispatches on an allow-list
|
||||||
|
# of widget names; anything missing from it silently falls through to a
|
||||||
|
# plain <input type="text">. That produced two boxes on the calendar
|
||||||
|
# page -- the widget's own, and a stray one for the same field -- and
|
||||||
|
# no way to tell which to paste into.
|
||||||
|
template = (Path(project_root)
|
||||||
|
/ 'web_interface/templates/v3/partials/plugin_config.html'
|
||||||
|
).read_text(encoding='utf-8')
|
||||||
|
allow_list_line = [ln for ln in template.splitlines()
|
||||||
|
if "str_widget in [" in ln]
|
||||||
|
assert allow_list_line, "the string widget allow-list moved"
|
||||||
|
assert "'google-oauth'" in allow_list_line[0], allow_list_line[0]
|
||||||
|
|
||||||
|
def test_the_widget_script_is_served(self):
|
||||||
|
base = (Path(project_root) / 'web_interface/templates/v3/base.html'
|
||||||
|
).read_text(encoding='utf-8')
|
||||||
|
assert 'widgets/google-oauth.js' in base
|
||||||
|
|
||||||
|
def test_the_failed_page_is_called_out_loudly(self):
|
||||||
|
# The loopback redirect lands on a browser error page at exactly the
|
||||||
|
# moment the user has to act. In small grey text it gets missed and the
|
||||||
|
# flow reads as broken while it is working.
|
||||||
|
widget = (Path(project_root)
|
||||||
|
/ 'web_interface/static/v3/js/widgets/google-oauth.js'
|
||||||
|
).read_text(encoding='utf-8')
|
||||||
|
assert 'expected' in widget.lower()
|
||||||
|
assert 'amber' in widget, "the warning is not visually distinguished"
|
||||||
|
|
||||||
|
|
||||||
|
class TestItSaysWhatIsWrong:
|
||||||
|
def test_listing_without_a_token_asks_for_step_2(self, client):
|
||||||
|
response = client.get('/api/v3/plugins/calendar/list-calendars')
|
||||||
|
assert response.status_code == 400
|
||||||
|
body = response.get_json()
|
||||||
|
assert body['status'] == 'error'
|
||||||
|
assert 'step 2' in body['message'].lower(), body['message']
|
||||||
|
|
||||||
|
def test_authenticating_without_credentials_asks_for_step_1(self, client):
|
||||||
|
response = client.post('/api/v3/plugins/calendar/authenticate', json={})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'step 1' in response.get_json()['message'].lower()
|
||||||
|
|
||||||
|
def test_an_uninstalled_plugin_says_so(self, uninstalled):
|
||||||
|
for response in (
|
||||||
|
uninstalled.get('/api/v3/plugins/calendar/list-calendars'),
|
||||||
|
uninstalled.post('/api/v3/plugins/calendar/authenticate', json={}),
|
||||||
|
):
|
||||||
|
assert response.status_code == 404
|
||||||
|
# A 404 here is honest -- but it must name the plugin, not read as
|
||||||
|
# the generic "Resource not found" that started this.
|
||||||
|
assert 'not installed' in response.get_json()['message'].lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheScriptRunner:
|
||||||
|
def test_it_returns_the_json_the_script_prints(self, tmp_path):
|
||||||
|
script = tmp_path / 'calendar_registration.py'
|
||||||
|
script.write_text(
|
||||||
|
'print(\'{"status": "success", "auth_url": "https://x"}\')\n',
|
||||||
|
encoding='utf-8')
|
||||||
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||||
|
assert error is None
|
||||||
|
assert payload['auth_url'] == 'https://x'
|
||||||
|
|
||||||
|
def test_it_ignores_noise_before_the_json(self, tmp_path):
|
||||||
|
# An import warning or a library writing to stdout would otherwise
|
||||||
|
# make the last-line parse fail.
|
||||||
|
script = tmp_path / 'calendar_registration.py'
|
||||||
|
script.write_text(
|
||||||
|
'print("some library warning")\n'
|
||||||
|
'print(\'{"status": "success"}\')\n', encoding='utf-8')
|
||||||
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||||
|
assert error is None and payload['status'] == 'success'
|
||||||
|
|
||||||
|
def test_it_passes_stdin_through(self, tmp_path):
|
||||||
|
script = tmp_path / 'calendar_registration.py'
|
||||||
|
script.write_text(
|
||||||
|
'import sys, json\n'
|
||||||
|
'print(json.dumps({"status": "success", "got": sys.stdin.read().strip()}))\n',
|
||||||
|
encoding='utf-8')
|
||||||
|
payload, _ = mod._run_calendar_registration(tmp_path, 'http://127.0.0.1/?code=abc')
|
||||||
|
assert payload['got'] == 'http://127.0.0.1/?code=abc'
|
||||||
|
|
||||||
|
def test_a_missing_script_is_reported(self, tmp_path):
|
||||||
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||||
|
assert payload is None
|
||||||
|
assert 'script not found' in error.lower()
|
||||||
|
|
||||||
|
def test_output_that_is_not_json_is_reported_with_context(self, tmp_path):
|
||||||
|
script = tmp_path / 'calendar_registration.py'
|
||||||
|
script.write_text('import sys\nsys.stderr.write("boom\\n")\n', encoding='utf-8')
|
||||||
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||||
|
assert payload is None
|
||||||
|
assert 'no result' in error.lower()
|
||||||
|
assert 'boom' in error
|
||||||
|
|
||||||
|
|
||||||
|
class TestListingShape:
|
||||||
|
"""The picker reads cal.id, cal.summary and cal.primary."""
|
||||||
|
|
||||||
|
def _authenticate(self, client, monkeypatch, items):
|
||||||
|
creds = type('C', (), {'expired': False, 'refresh_token': None, 'valid': True})()
|
||||||
|
(client.plugin_dir / 'token.pickle').write_bytes(pickle.dumps({'x': 1}))
|
||||||
|
monkeypatch.setattr(mod.pickle if hasattr(mod, 'pickle') else pickle,
|
||||||
|
'loads', lambda *a, **k: creds, raising=False)
|
||||||
|
|
||||||
|
import types
|
||||||
|
fake_pickle = types.SimpleNamespace(load=lambda f: creds, dump=lambda *a: None)
|
||||||
|
pages = items if isinstance(items, list) and items and isinstance(items[0], dict) \
|
||||||
|
else items
|
||||||
|
if isinstance(pages, list):
|
||||||
|
pages = [{'items': pages}]
|
||||||
|
|
||||||
|
state = {'i': 0}
|
||||||
|
|
||||||
|
def fake_list(**kwargs):
|
||||||
|
page = pages[min(state['i'], len(pages) - 1)]
|
||||||
|
state['i'] += 1
|
||||||
|
return types.SimpleNamespace(execute=lambda: page)
|
||||||
|
|
||||||
|
def fake_build(*args, **kwargs):
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
calendarList=lambda: types.SimpleNamespace(list=fake_list))
|
||||||
|
|
||||||
|
real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
|
||||||
|
else __builtins__.__import__
|
||||||
|
|
||||||
|
def fake_import(name, *args, **kwargs):
|
||||||
|
if name == 'pickle':
|
||||||
|
return fake_pickle
|
||||||
|
if name == 'google.auth.transport.requests':
|
||||||
|
return types.SimpleNamespace(Request=object)
|
||||||
|
if name == 'googleapiclient.discovery':
|
||||||
|
return types.SimpleNamespace(build=fake_build)
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr('builtins.__import__', fake_import)
|
||||||
|
|
||||||
|
def test_it_returns_id_summary_and_primary(self, client, monkeypatch):
|
||||||
|
self._authenticate(client, monkeypatch, [
|
||||||
|
{'id': 'b@x', 'summary': 'Work'},
|
||||||
|
{'id': 'a@x', 'summary': 'Personal', 'primary': True},
|
||||||
|
])
|
||||||
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||||
|
assert body['status'] == 'success'
|
||||||
|
assert {c['id'] for c in body['calendars']} == {'a@x', 'b@x'}
|
||||||
|
assert all(set(c) == {'id', 'summary', 'primary'} for c in body['calendars'])
|
||||||
|
|
||||||
|
def test_the_primary_calendar_comes_first(self, client, monkeypatch):
|
||||||
|
# Short list, but the one the user wants is almost always their own.
|
||||||
|
self._authenticate(client, monkeypatch, [
|
||||||
|
{'id': 'z@x', 'summary': 'Aardvarks'},
|
||||||
|
{'id': 'a@x', 'summary': 'Zebras', 'primary': True},
|
||||||
|
])
|
||||||
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||||
|
assert body['calendars'][0]['id'] == 'a@x'
|
||||||
|
assert body['calendars'][0]['primary'] is True
|
||||||
|
|
||||||
|
def test_a_calendar_without_a_name_still_lists(self, client, monkeypatch):
|
||||||
|
self._authenticate(client, monkeypatch, [{'id': 'noname@x'}])
|
||||||
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||||
|
assert body['calendars'][0]['summary'] == 'noname@x'
|
||||||
|
|
||||||
|
def test_entries_without_an_id_are_dropped(self, client, monkeypatch):
|
||||||
|
# Nothing could be selected by such a row, and the checkbox value
|
||||||
|
# would be undefined.
|
||||||
|
self._authenticate(client, monkeypatch, [{'summary': 'ghost'}, {'id': 'real@x'}])
|
||||||
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||||
|
assert [c['id'] for c in body['calendars']] == ['real@x']
|
||||||
|
|
||||||
|
|
||||||
|
class TestPagination:
|
||||||
|
"""calendarList.list pages at 250 and defaults to 100."""
|
||||||
|
|
||||||
|
def _paged(self, client, monkeypatch, pages):
|
||||||
|
import types
|
||||||
|
creds = type('C', (), {'expired': False, 'refresh_token': None, 'valid': True})()
|
||||||
|
(client.plugin_dir / 'token.pickle').write_bytes(b'x')
|
||||||
|
state = {'i': 0}
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fake_list(**kwargs):
|
||||||
|
seen.append(kwargs)
|
||||||
|
page = pages[min(state['i'], len(pages) - 1)]
|
||||||
|
state['i'] += 1
|
||||||
|
return types.SimpleNamespace(execute=lambda: page)
|
||||||
|
|
||||||
|
def fake_build(*args, **kwargs):
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
calendarList=lambda: types.SimpleNamespace(list=fake_list))
|
||||||
|
|
||||||
|
real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
|
||||||
|
else __builtins__.__import__
|
||||||
|
|
||||||
|
def fake_import(name, *args, **kwargs):
|
||||||
|
if name == 'pickle':
|
||||||
|
return types.SimpleNamespace(load=lambda f: creds, dump=lambda *a: None)
|
||||||
|
if name == 'google.auth.transport.requests':
|
||||||
|
return types.SimpleNamespace(Request=object)
|
||||||
|
if name == 'googleapiclient.discovery':
|
||||||
|
return types.SimpleNamespace(build=fake_build)
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr('builtins.__import__', fake_import)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
def test_every_page_is_collected(self, client, monkeypatch):
|
||||||
|
# Taking only the first page would hide calendars from the picker with
|
||||||
|
# nothing to say the list was cut short.
|
||||||
|
self._paged(client, monkeypatch, [
|
||||||
|
{'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 't1'},
|
||||||
|
{'items': [{'id': 'b@x', 'summary': 'B'}], 'nextPageToken': 't2'},
|
||||||
|
{'items': [{'id': 'c@x', 'summary': 'C'}]},
|
||||||
|
])
|
||||||
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||||
|
assert [c['id'] for c in body['calendars']] == ['a@x', 'b@x', 'c@x']
|
||||||
|
|
||||||
|
def test_the_page_token_is_passed_back(self, client, monkeypatch):
|
||||||
|
seen = self._paged(client, monkeypatch, [
|
||||||
|
{'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 'tok'},
|
||||||
|
{'items': [{'id': 'b@x', 'summary': 'B'}]},
|
||||||
|
])
|
||||||
|
client.get('/api/v3/plugins/calendar/list-calendars')
|
||||||
|
assert seen[0]['pageToken'] is None
|
||||||
|
assert seen[1]['pageToken'] == 'tok'
|
||||||
|
assert all(k['maxResults'] == 250 for k in seen)
|
||||||
|
|
||||||
|
def test_a_looping_token_cannot_spin_forever(self, client, monkeypatch):
|
||||||
|
# Every page claims another follows.
|
||||||
|
self._paged(client, monkeypatch, [
|
||||||
|
{'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 'same'},
|
||||||
|
])
|
||||||
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||||
|
assert body['status'] == 'success'
|
||||||
|
assert len(body['calendars']) <= mod._CALENDAR_LIST_MAX_PAGES
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiagnosticsAreRedacted:
|
||||||
|
def test_script_stderr_is_redacted_on_the_way_out(self, tmp_path):
|
||||||
|
script = tmp_path / 'calendar_registration.py'
|
||||||
|
script.write_text(
|
||||||
|
'import sys\n'
|
||||||
|
'sys.stderr.write("boom client_secret=hunter2 more\\n")\n',
|
||||||
|
encoding='utf-8')
|
||||||
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||||
|
assert payload is None
|
||||||
|
assert 'hunter2' not in error, error
|
||||||
|
assert '<redacted>' in error, error
|
||||||
|
|
||||||
|
def test_a_failing_script_payload_is_redacted(self, client):
|
||||||
|
(client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
|
||||||
|
(client.plugin_dir / 'calendar_registration.py').write_text(
|
||||||
|
'import json\n'
|
||||||
|
'print(json.dumps({"status": "error", '
|
||||||
|
'"message": "Failed: client_secret=topsecret"}))\n',
|
||||||
|
encoding='utf-8')
|
||||||
|
body = client.post('/api/v3/plugins/calendar/authenticate',
|
||||||
|
json={}).get_json()
|
||||||
|
assert body['status'] == 'error'
|
||||||
|
assert 'topsecret' not in json.dumps(body), body
|
||||||
|
assert '<redacted>' in body['message'], body
|
||||||
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
|
|||||||
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
|
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.errors import ErrorCode
|
||||||
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
||||||
from src.web_interface.error_handler import describe_exception
|
from src.web_interface.error_handler import describe_exception, redact_text
|
||||||
from src.plugin_system.operation_types import OperationType
|
from src.plugin_system.operation_types import OperationType
|
||||||
from src.web_interface.validators import (
|
from src.web_interface.validators import (
|
||||||
validate_file_upload
|
validate_file_upload
|
||||||
@@ -7312,6 +7312,222 @@ def upload_calendar_credentials():
|
|||||||
logger.error('Error in upload_calendar_credentials', exc_info=True)
|
logger.error('Error in upload_calendar_credentials', exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
# calendarList.list pages at 250 entries maximum. Ten pages is far past any
|
||||||
|
# real account and exists only so a malformed nextPageToken cannot spin here.
|
||||||
|
_CALENDAR_LIST_MAX_PAGES = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_plugin_dir() -> Optional[Path]:
|
||||||
|
"""Where the calendar plugin is installed, or None if it is not."""
|
||||||
|
if api_v3.plugin_manager:
|
||||||
|
plugin_dir = api_v3.plugin_manager.get_plugin_directory('calendar')
|
||||||
|
else:
|
||||||
|
plugin_dir = PROJECT_ROOT / 'plugins' / 'calendar'
|
||||||
|
if not plugin_dir:
|
||||||
|
return None
|
||||||
|
plugin_dir = Path(plugin_dir)
|
||||||
|
return plugin_dir if plugin_dir.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_calendar_registration(plugin_dir: Path, stdin_payload: str):
|
||||||
|
"""Run the plugin's OAuth script and return the JSON object it prints.
|
||||||
|
|
||||||
|
The script decides between web and terminal mode by whether stdin is a
|
||||||
|
tty, so it must be given a pipe. It emits one JSON object on stdout; the
|
||||||
|
last parsable line is taken, because an import warning or a library's
|
||||||
|
stderr redirection can land in front of it.
|
||||||
|
|
||||||
|
Returns (payload, error_message). Exactly one is None.
|
||||||
|
"""
|
||||||
|
script = plugin_dir / 'calendar_registration.py'
|
||||||
|
if not script.exists():
|
||||||
|
return None, 'Authentication script not found in the calendar plugin'
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run( # nosec B603 - fixed script path inside the plugin dir
|
||||||
|
[sys.executable, str(script)],
|
||||||
|
input=stdin_payload,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
cwd=str(plugin_dir),
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return None, 'Authentication timed out after 120s'
|
||||||
|
except OSError as e:
|
||||||
|
return None, 'Could not run the authentication script: %s' % e
|
||||||
|
|
||||||
|
for line in reversed((result.stdout or '').splitlines()):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
return payload, None
|
||||||
|
|
||||||
|
raw = (result.stderr or result.stdout or '').strip()
|
||||||
|
# The unredacted text goes to the log, where it is worth having in full.
|
||||||
|
# What comes back over HTTP is redacted: this is a script that handles
|
||||||
|
# OAuth client secrets, and its stderr can quote them.
|
||||||
|
if raw:
|
||||||
|
logger.error('calendar_registration.py failed (exit %s): %s',
|
||||||
|
result.returncode, raw)
|
||||||
|
return None, 'Authentication script produced no result%s' % (
|
||||||
|
': %s' % redact_text(raw) if raw else '')
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/plugins/calendar/authenticate', methods=['POST'])
|
||||||
|
def authenticate_calendar():
|
||||||
|
"""Google OAuth for the calendar plugin, in the two steps it requires.
|
||||||
|
|
||||||
|
Step 1 (no body) returns the consent URL to open. Step 2 posts back the
|
||||||
|
URL Google redirected to -- it fails to load, because the redirect points
|
||||||
|
at a loopback address nothing is listening on, but the address bar carries
|
||||||
|
the authorization code -- and the script exchanges it for a token.
|
||||||
|
|
||||||
|
Two calls rather than one because the user has to visit Google in between.
|
||||||
|
The script persists the PKCE verifier from step 1 for step 2 to reuse; the
|
||||||
|
exchange fails with "Missing code verifier" otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
plugin_dir = _calendar_plugin_dir()
|
||||||
|
if plugin_dir is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'The calendar plugin is not installed'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
if not (plugin_dir / 'credentials.json').exists():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': ('No credentials.json yet. Upload your Google OAuth '
|
||||||
|
'client file first (Step 1).')
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
redirect_url = (data.get('redirect_url') or data.get('code') or '').strip()
|
||||||
|
|
||||||
|
payload, error = _run_calendar_registration(plugin_dir, redirect_url)
|
||||||
|
if error:
|
||||||
|
return jsonify({'status': 'error', 'message': error}), 500
|
||||||
|
if payload.get('status') != 'success':
|
||||||
|
# The script's own diagnosis is more useful than anything that
|
||||||
|
# could be reconstructed here -- but it interpolates exceptions
|
||||||
|
# into its messages, so it reaches the client redacted and the
|
||||||
|
# original goes to the log.
|
||||||
|
logger.error('calendar authentication failed: %s', payload)
|
||||||
|
safe = dict(payload)
|
||||||
|
safe['message'] = redact_text(str(payload.get('message', '')
|
||||||
|
or 'Authentication failed'))
|
||||||
|
return jsonify(safe), 400
|
||||||
|
return jsonify(payload)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error in authenticate_calendar', exc_info=True)
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/plugins/calendar/list-calendars', methods=['GET'])
|
||||||
|
def list_calendar_calendars():
|
||||||
|
"""The calendars this account can see, for the config picker.
|
||||||
|
|
||||||
|
Reads the token the OAuth flow wrote rather than shelling out again: the
|
||||||
|
picker is used interactively and a subprocess per click is slower than the
|
||||||
|
API call it would be wrapping.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
plugin_dir = _calendar_plugin_dir()
|
||||||
|
if plugin_dir is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'The calendar plugin is not installed'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
token_file = plugin_dir / 'token.pickle'
|
||||||
|
if not token_file.exists():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': ('Not authenticated with Google yet. Complete Step 2 '
|
||||||
|
'first, then load your calendars.')
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pickle
|
||||||
|
from google.auth.transport.requests import Request as GoogleRequest
|
||||||
|
from googleapiclient.discovery import build as build_google_service
|
||||||
|
except ImportError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': ('The Google API libraries are not installed. Install '
|
||||||
|
"the calendar plugin's requirements.txt. (%s)" % e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
with open(token_file, 'rb') as handle:
|
||||||
|
# Written only by this plugin's own OAuth flow, into its own
|
||||||
|
# directory, and read here exactly as the plugin itself reads it.
|
||||||
|
creds = pickle.load(handle) # nosec B301 - locally generated token
|
||||||
|
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
creds.refresh(GoogleRequest())
|
||||||
|
with open(token_file, 'wb') as handle:
|
||||||
|
pickle.dump(creds, handle)
|
||||||
|
os.chmod(token_file, 0o600)
|
||||||
|
|
||||||
|
if not creds or not creds.valid:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': ('Stored Google credentials are no longer valid. '
|
||||||
|
'Run Step 2 again to re-authenticate.')
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
service = build_google_service('calendar', 'v3', credentials=creds)
|
||||||
|
|
||||||
|
# calendarList.list returns 100 entries per page by default and caps at
|
||||||
|
# 250, handing back a nextPageToken when there are more. Taking only
|
||||||
|
# the first page would silently hide calendars from the picker, and the
|
||||||
|
# user would have no way to tell the list was truncated.
|
||||||
|
entries = []
|
||||||
|
page_token = None
|
||||||
|
for _ in range(_CALENDAR_LIST_MAX_PAGES):
|
||||||
|
response = service.calendarList().list(
|
||||||
|
maxResults=250, pageToken=page_token).execute()
|
||||||
|
entries.extend(response.get('items', []))
|
||||||
|
page_token = response.get('nextPageToken')
|
||||||
|
if not page_token:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# 2500 calendars in, something is wrong with the account or the
|
||||||
|
# token is looping; show what was collected rather than spin.
|
||||||
|
logger.warning(
|
||||||
|
'calendarList paging stopped at %d pages with more remaining',
|
||||||
|
_CALENDAR_LIST_MAX_PAGES)
|
||||||
|
|
||||||
|
calendars = [{
|
||||||
|
'id': entry.get('id'),
|
||||||
|
# The picker labels each row with summary and falls back to the id
|
||||||
|
# only in its own display, so send something either way.
|
||||||
|
'summary': entry.get('summary') or entry.get('id'),
|
||||||
|
'primary': bool(entry.get('primary', False)),
|
||||||
|
} for entry in entries if entry.get('id')]
|
||||||
|
|
||||||
|
# Primary first, then alphabetically: the list is usually short but the
|
||||||
|
# one the user wants is almost always their own calendar.
|
||||||
|
calendars.sort(key=lambda c: (not c['primary'], c['summary'].lower()))
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'calendars': calendars})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error in list_calendar_calendars', exc_info=True)
|
||||||
|
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'])
|
@api_v3.route('/plugins/assets/delete', methods=['POST'])
|
||||||
def delete_plugin_asset():
|
def delete_plugin_asset():
|
||||||
"""Delete an asset file for a plugin"""
|
"""Delete an asset file for a plugin"""
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Google OAuth Widget
|
||||||
|
*
|
||||||
|
* Step 2 of the calendar plugin's setup, between uploading the OAuth client
|
||||||
|
* file and picking calendars. Google will not let a headless device complete
|
||||||
|
* consent on its own, so the flow is necessarily two calls with a human in
|
||||||
|
* between:
|
||||||
|
*
|
||||||
|
* 1. POST /api/v3/plugins/calendar/authenticate with no body
|
||||||
|
* -> { auth_url } to open in a browser
|
||||||
|
* 2. the browser lands on a loopback address that fails to load; its URL
|
||||||
|
* carries the authorization code. POST it back as redirect_url
|
||||||
|
* -> the server exchanges it and writes token.pickle
|
||||||
|
*
|
||||||
|
* The failed page in step 2 is expected and is worth saying out loud, because
|
||||||
|
* it looks exactly like something went wrong.
|
||||||
|
*
|
||||||
|
* @module GoogleOAuthWidget
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||||
|
console.error('[GoogleOAuthWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENDPOINT = '/api/v3/plugins/calendar/authenticate';
|
||||||
|
|
||||||
|
window.LEDMatrixWidgets.register('google-oauth', {
|
||||||
|
name: 'Google OAuth Widget',
|
||||||
|
version: '1.0.0',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @param {Object} config - schema config (unused)
|
||||||
|
* @param {*} value - unused; this widget stores nothing
|
||||||
|
* @param {Object} options - { fieldId, pluginId, name }
|
||||||
|
*/
|
||||||
|
render: function (container, config, value, options) {
|
||||||
|
const fieldId = options.fieldId;
|
||||||
|
|
||||||
|
// Nothing is stored in config by this step -- the result is
|
||||||
|
// token.pickle on the device -- but the form still expects a field.
|
||||||
|
const hidden = document.createElement('input');
|
||||||
|
hidden.type = 'hidden';
|
||||||
|
hidden.id = fieldId + '_hidden';
|
||||||
|
hidden.name = options.name;
|
||||||
|
hidden.value = value || '';
|
||||||
|
|
||||||
|
const startBtn = document.createElement('button');
|
||||||
|
startBtn.type = 'button';
|
||||||
|
startBtn.className = 'px-3 py-1.5 text-sm rounded-md bg-blue-600 hover:bg-blue-700 text-white';
|
||||||
|
startBtn.innerHTML = '<i class="fas fa-key"></i> Connect Google Account';
|
||||||
|
|
||||||
|
const status = document.createElement('p');
|
||||||
|
status.className = 'text-xs text-gray-400 mt-2';
|
||||||
|
|
||||||
|
const step2 = document.createElement('div');
|
||||||
|
step2.className = 'mt-3 hidden';
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
|
link.className = 'text-blue-400 underline text-sm break-all';
|
||||||
|
link.textContent = 'Open the Google consent screen';
|
||||||
|
|
||||||
|
// Deliberately loud. After consent the browser is redirected to a
|
||||||
|
// loopback address nothing is listening on, so it lands on a
|
||||||
|
// browser error page -- which reads as a failure at exactly the
|
||||||
|
// moment the user has to act on it. Said quietly in grey it gets
|
||||||
|
// missed, and the flow looks broken when it is working.
|
||||||
|
const hint = document.createElement('div');
|
||||||
|
hint.className =
|
||||||
|
'mt-3 p-3 rounded-md border border-amber-500/60 bg-amber-500/10';
|
||||||
|
hint.innerHTML =
|
||||||
|
'<p class="text-sm text-amber-300 font-semibold">'
|
||||||
|
+ '<i class="fas fa-triangle-exclamation"></i> '
|
||||||
|
+ 'The next page will fail to load. That is expected.</p>'
|
||||||
|
+ '<p class="text-xs text-amber-200/90 mt-1">'
|
||||||
|
+ 'After you approve access, Google sends your browser to '
|
||||||
|
+ '<code>127.0.0.1</code>, where nothing is running \u2014 so you will see '
|
||||||
|
+ '"This site can\u2019t be reached" or similar. Nothing has gone wrong. '
|
||||||
|
+ 'Copy the <strong>entire address</strong> out of the address bar '
|
||||||
|
+ '(it contains <code>?code=...</code>) and paste it in the box below.</p>';
|
||||||
|
|
||||||
|
const codeLabel = document.createElement('label');
|
||||||
|
codeLabel.className = 'block text-xs text-gray-300 mt-3';
|
||||||
|
codeLabel.textContent = 'Paste the address from that failed page here:';
|
||||||
|
|
||||||
|
const codeInput = document.createElement('input');
|
||||||
|
codeInput.type = 'text';
|
||||||
|
codeInput.placeholder = 'http://127.0.0.1/?code=...';
|
||||||
|
codeInput.className =
|
||||||
|
'mt-2 block w-full px-3 py-2 text-sm border border-gray-600 '
|
||||||
|
+ 'rounded-md bg-gray-800 text-gray-100';
|
||||||
|
|
||||||
|
const finishBtn = document.createElement('button');
|
||||||
|
finishBtn.type = 'button';
|
||||||
|
finishBtn.className = 'mt-2 px-3 py-1.5 text-sm rounded-md bg-green-600 hover:bg-green-700 text-white';
|
||||||
|
finishBtn.innerHTML = '<i class="fas fa-check"></i> Finish Authentication';
|
||||||
|
|
||||||
|
function say(message, kind) {
|
||||||
|
status.textContent = message;
|
||||||
|
status.className = 'text-xs mt-2 ' + (
|
||||||
|
kind === 'error' ? 'text-red-400'
|
||||||
|
: kind === 'success' ? 'text-green-400'
|
||||||
|
: 'text-gray-400');
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(body) {
|
||||||
|
return fetch(ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body || {})
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().catch(function () {
|
||||||
|
// A non-JSON body here means the request never reached
|
||||||
|
// the handler -- worth saying so rather than "undefined".
|
||||||
|
return { status: 'error', message: 'Server returned ' + r.status };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startBtn.addEventListener('click', function () {
|
||||||
|
startBtn.disabled = true;
|
||||||
|
say('Requesting a consent link...');
|
||||||
|
post({}).then(function (data) {
|
||||||
|
startBtn.disabled = false;
|
||||||
|
if (data.status !== 'success' || !data.auth_url) {
|
||||||
|
say(data.message || 'Could not start authentication.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
link.href = data.auth_url;
|
||||||
|
step2.classList.remove('hidden');
|
||||||
|
say(data.message || 'Open the link, approve, then paste the address back.');
|
||||||
|
}).catch(function (err) {
|
||||||
|
startBtn.disabled = false;
|
||||||
|
say('Request failed: ' + err.message, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
finishBtn.addEventListener('click', function () {
|
||||||
|
const pasted = codeInput.value.trim();
|
||||||
|
if (!pasted) {
|
||||||
|
say('Paste the address your browser was redirected to.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishBtn.disabled = true;
|
||||||
|
say('Exchanging the code with Google...');
|
||||||
|
post({ redirect_url: pasted }).then(function (data) {
|
||||||
|
finishBtn.disabled = false;
|
||||||
|
if (data.status !== 'success') {
|
||||||
|
say(data.message || 'Authentication failed.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
say(data.message || 'Authenticated.', 'success');
|
||||||
|
step2.classList.add('hidden');
|
||||||
|
codeInput.value = '';
|
||||||
|
}).catch(function (err) {
|
||||||
|
finishBtn.disabled = false;
|
||||||
|
say('Request failed: ' + err.message, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
step2.appendChild(link);
|
||||||
|
step2.appendChild(hint);
|
||||||
|
step2.appendChild(codeLabel);
|
||||||
|
step2.appendChild(codeInput);
|
||||||
|
step2.appendChild(finishBtn);
|
||||||
|
|
||||||
|
container.appendChild(hidden);
|
||||||
|
container.appendChild(startBtn);
|
||||||
|
container.appendChild(status);
|
||||||
|
container.appendChild(step2);
|
||||||
|
},
|
||||||
|
|
||||||
|
getValue: function (fieldId) {
|
||||||
|
const hidden = document.getElementById(fieldId + '_hidden');
|
||||||
|
return hidden ? hidden.value : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -987,6 +987,7 @@
|
|||||||
<script src="{{ url_for('static', filename='v3/js/widgets/custom-feeds.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/custom-feeds.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/array-table.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/array-table.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/google-calendar-picker.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/google-calendar-picker.js') }}" defer></script>
|
||||||
|
<script src="{{ url_for('static', filename='v3/js/widgets/google-oauth.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/day-selector.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/day-selector.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/time-range.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/time-range.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/time-picker.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/time-picker.js') }}" defer></script>
|
||||||
|
|||||||
@@ -815,7 +815,7 @@
|
|||||||
<i class="fas fa-info-circle mr-1"></i>
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
Changes in the file manager save immediately — no need to click Save Configuration.
|
Changes in the file manager save immediately — no need to click Save Configuration.
|
||||||
</p>
|
</p>
|
||||||
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager'] %}
|
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager', 'google-oauth'] %}
|
||||||
{# Render widget container #}
|
{# Render widget container #}
|
||||||
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
Reference in New Issue
Block a user