kryten-webqueue 0.9.13__tar.gz → 0.14.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/CHANGELOG.md +49 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/config.example.json +26 -0
- kryten_webqueue-0.14.0/docs/PLAN_PRESENCE_AND_PROMOS.md +424 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/app.py +22 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/db.py +57 -15
- kryten_webqueue-0.14.0/kryten_webqueue/config.py +170 -0
- kryten_webqueue-0.14.0/kryten_webqueue/promos/__init__.py +12 -0
- kryten_webqueue-0.14.0/kryten_webqueue/promos/director.py +372 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/ordering.py +21 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/poller.py +8 -1
- kryten_webqueue-0.14.0/kryten_webqueue/queue/presence.py +203 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/shadow.py +3 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_playlists.py +16 -0
- kryten_webqueue-0.14.0/kryten_webqueue/routes/admin_promos.py +64 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/pages.py +8 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/queue.py +2 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/static/css/main.css +25 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/index.html +1 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/playlists.html +3 -1
- kryten_webqueue-0.14.0/kryten_webqueue/templates/admin/promos.html +212 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/queue/index.html +11 -1
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/pyproject.toml +1 -1
- kryten_webqueue-0.14.0/tests/test_config_persistence.py +54 -0
- kryten_webqueue-0.14.0/tests/test_presence_refund.py +268 -0
- kryten_webqueue-0.14.0/tests/test_promo_director.py +376 -0
- kryten_webqueue-0.14.0/tests/test_promo_pool_exclusion.py +84 -0
- kryten_webqueue-0.9.13/kryten_webqueue/config.py +0 -76
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/.gitignore +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/README.md +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/test_queue_announce.py +0 -0
|
@@ -6,6 +6,55 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.14.0] — 2026-06-13
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Inline promo settings editing (plan O2).** The **Promos** admin page settings panel is now editable: toggle the system enable, set the movie threshold, general cadence (`every_n_items` / `every_m_minutes` / `no_repeat`), and per-type `enabled` / `order` / `weight`, then **Save**. Changes are persisted to the service config file and hot-applied to the running `PromoDirector` — no restart required. Backend: `PUT /admin/promos/config` (admin-only) plus `Config.save()` (atomic write back to the loaded config file) and `PromoDirector.update_config()`.
|
|
14
|
+
- **Cancel/refund notifications (plan O4).** When the presence monitor cancels & refunds a pending paid item, the owner now receives a PM explaining it ("… cancelled and refunded because you left the channel / you went AFK"). Controlled by `presence_refund.notify_user` (default on); best-effort so a failed PM never blocks the refund.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **`presence_refund.on_afk` now defaults on (plan O1).** AFK-based cancel/refund is enabled by default now that Kryten-Robot v1.10.0 (which tracks CyTube's `setAFK` event) is released. Set it off if running against an older Robot.
|
|
19
|
+
|
|
20
|
+
### Tested
|
|
21
|
+
|
|
22
|
+
- DB-level test asserting promo pool clips are hidden from browse/search and rejected by pay-to-play (`get_item`), and become visible again when the `promo_type` is cleared (plan §2.9 gap).
|
|
23
|
+
- Config save round-trip + no-source-path guard; presence-cancel PM (sent / suppressed) coverage.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **Promo admin UI.** A new **Promos** admin page (`/admin/promos`, linked from the admin panel):
|
|
29
|
+
- **Promo pool designation** — assign any saved playlist a promo type from a dropdown (or clear it), reusing the existing playlist editor for the clips. Immutable playlists are excluded (release them first). Promo pools are flagged with a badge on the Playlists page.
|
|
30
|
+
- **Promo settings panel** — read-only display of the live `promos` configuration (system enable, movie threshold, general cadence, and a per-type table of enabled/order/weight). Inline editing is a planned follow-up; values come from the config file.
|
|
31
|
+
- **Live-queue promo badges.** Promo items in the public queue view now render distinctly, badged by promo type (Channel ID, Event, Mod Shoutout, Feature, Viewer's Choice) with a separate accent.
|
|
32
|
+
- Backend: `GET /admin/promos/config` and `GET /admin/promos/pools` (admin-only).
|
|
33
|
+
|
|
34
|
+
## [0.11.0] — 2026-06-13
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **Promo insertion system (`PromoDirector`).** Curated promo clips are now inserted between mutable content as playback advances, driven by the state poller:
|
|
39
|
+
- **General promos** (`channel_identity`, `event`, `mod_shoutout`) inserted on a cadence — every N content items or every M minutes, whichever comes first — with weighted type selection and per-type `random`/`sequential` clip ordering plus an optional `no_repeat` guard.
|
|
40
|
+
- **Feature Presentation** lead-in immediately before a mutable-playlist movie (`duration_sec >= movie_threshold_seconds`).
|
|
41
|
+
- **Viewer's Choice** lead-in immediately before any pay-to-play item (a paid movie gets Viewer's Choice, never Feature Presentation). Inserted synchronously at pay time so it is in place before a "play next" item can begin, and removed automatically if the paid item is later cancelled/refunded (including presence-based cancels).
|
|
42
|
+
- When both are due before the same item the order is `[general][lead-in][content]`. Promos are added as CyTube **temp** items (via the throttled add helper) so they auto-remove after playing and never accumulate across loops. The director is a no-op during an immutable scheduled event.
|
|
43
|
+
- **Promo pools.** A saved playlist can be designated a promo pool by tagging it with a `promo_type` (via the playlist create/update API). Promo pools are hidden from public browse/search and excluded from pay-to-play, the same treatment as immutable playlists.
|
|
44
|
+
- Config: `promos` block — global `enabled`, `movie_threshold_seconds`, a `general` cadence block (`every_n_items`, `every_m_minutes`, `no_repeat`), and per-type `enabled`/`order`/`weight` settings.
|
|
45
|
+
- DB: migration v10 (`promo_type` on `saved_playlists` + index) and v11 (`is_promo`, `promo_type`, `lead_in_for_uid` on `queue_shadow`).
|
|
46
|
+
|
|
47
|
+
### Note
|
|
48
|
+
|
|
49
|
+
- Backend promo designation and the full insertion engine ship in this release. A dedicated admin promo-settings panel and live-queue promo badges are a planned follow-up; pools can be designated today via the playlist API and `promos` config is file-based.
|
|
50
|
+
|
|
51
|
+
## [0.10.0] — 2026-06-13
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- **Presence-based cancel/refund of pending paid items.** When a viewer who paid to queue an item leaves the channel (or goes AFK), their not-yet-played paid items are now automatically refunded and removed after a configurable grace period. The currently-playing item is never cancelled, and free/scheduled items are left untouched. If the owner returns before the grace window elapses the item is kept; transient api-gate/robot lookup failures are treated as inconclusive and never trigger a cancellation. Implemented by a new `PresenceRefundMonitor` running on its own interval (decoupled from the 3s state poll).
|
|
56
|
+
- Config: `presence_refund` block — `enabled` (default `true`), `on_leave` (default `true`), `on_afk` (default `false`; enable once Kryten-Robot ≥ 1.10.0, which tracks CyTube's `setAFK` event, is deployed), `grace_seconds` (default `60`), and `check_interval_seconds` (default `15`).
|
|
57
|
+
|
|
9
58
|
## [0.9.13] — 2026-06-12
|
|
10
59
|
|
|
11
60
|
### Fixed
|
|
@@ -23,6 +23,32 @@
|
|
|
23
23
|
"token_cache_path": "/var/lib/kryten-webqueue/.fetchurls_tokens.bin"
|
|
24
24
|
},
|
|
25
25
|
|
|
26
|
+
"presence_refund": {
|
|
27
|
+
"enabled": true,
|
|
28
|
+
"on_leave": true,
|
|
29
|
+
"on_afk": true,
|
|
30
|
+
"grace_seconds": 60.0,
|
|
31
|
+
"check_interval_seconds": 15.0,
|
|
32
|
+
"notify_user": true
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
"promos": {
|
|
36
|
+
"enabled": true,
|
|
37
|
+
"movie_threshold_seconds": 3600.0,
|
|
38
|
+
"general": {
|
|
39
|
+
"every_n_items": 4,
|
|
40
|
+
"every_m_minutes": 20.0,
|
|
41
|
+
"no_repeat": true
|
|
42
|
+
},
|
|
43
|
+
"types": {
|
|
44
|
+
"channel_identity": { "enabled": true, "order": "random", "weight": 3 },
|
|
45
|
+
"event": { "enabled": true, "order": "random", "weight": 2 },
|
|
46
|
+
"mod_shoutout": { "enabled": true, "order": "sequential", "weight": 1 },
|
|
47
|
+
"feature_presentation": { "enabled": true, "order": "random", "weight": 1 },
|
|
48
|
+
"viewers_choice": { "enabled": true, "order": "random", "weight": 1 }
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
26
52
|
"db_path": "/var/lib/kryten-webqueue/webqueue.db",
|
|
27
53
|
|
|
28
54
|
"image_dir": "/var/lib/kryten-webqueue/images",
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# Plan — Viewer Presence Refunds & Promo Insertion System
|
|
2
|
+
|
|
3
|
+
Status: **proposed** (planning only — no code yet)
|
|
4
|
+
Target component: `kryten-webqueue`
|
|
5
|
+
Author: design session 2026-06-13
|
|
6
|
+
|
|
7
|
+
This document plans two features requested for the "done for now" milestone:
|
|
8
|
+
|
|
9
|
+
1. **Presence-based cancel/refund** — when a viewer who paid to queue an item
|
|
10
|
+
leaves the channel or goes AFK, cancel and refund their not-yet-played paid
|
|
11
|
+
items.
|
|
12
|
+
2. **Promo insertion system** — maintain curated promo playlists and insert
|
|
13
|
+
short promos between content while a *mutable* playlist is playing, including
|
|
14
|
+
special movie / pay-to-play lead-ins.
|
|
15
|
+
|
|
16
|
+
The decisions below were confirmed in the planning interview; the
|
|
17
|
+
"Resolved decisions" subsections are authoritative.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 0. Current architecture (relevant facts)
|
|
22
|
+
|
|
23
|
+
- **Poller** ([`queue/poller.py`](../kryten_webqueue/queue/poller.py)) calls
|
|
24
|
+
api-gate every `state_poll_interval_sec` (default 3s), feeding
|
|
25
|
+
`QueueShadow.apply_poll_result(playlist, now_playing)`.
|
|
26
|
+
- **QueueShadow** ([`queue/shadow.py`](../kryten_webqueue/queue/shadow.py))
|
|
27
|
+
mirrors the live CyTube playlist in true play order; each item carries
|
|
28
|
+
`uid, position, title, media_type, media_id, duration_sec, is_pay, paid_by,
|
|
29
|
+
tier, z_cost, schedule_id`. It already auto-lifts the event lock when the last
|
|
30
|
+
scheduled item begins (`_maybe_lift_event_lock`).
|
|
31
|
+
- **Pay insertion / refund** ([`queue/ordering.py`](../kryten_webqueue/queue/ordering.py)):
|
|
32
|
+
`insert_pay_queue` / `insert_pay_playnext` spend → add → move → record
|
|
33
|
+
`spend_requests`; `refund_item(uid, reason)` looks up the `request_id` for a
|
|
34
|
+
uid and calls `api_gate.queue_refund`.
|
|
35
|
+
- **Saved playlists** are `saved_playlists` (+ `saved_playlist_items`) with an
|
|
36
|
+
`is_immutable` flag; immutable playlists are hidden from browse/search and
|
|
37
|
+
excluded from pay-to-play.
|
|
38
|
+
- **Mutable vs immutable**: `active_schedule.is_immutable` marks a curated event;
|
|
39
|
+
while true, pay-to-play is locked. "Mutable content" = everything that is **not**
|
|
40
|
+
a running immutable scheduled event.
|
|
41
|
+
- **User presence**: `ApiGateClient.get_user(username)` → robot `state.user`,
|
|
42
|
+
returning `{name, rank, online?, meta:{afk, ...}}`, or `{online: False}` when
|
|
43
|
+
the user is not in the channel. There is **no** userlist endpoint in api-gate,
|
|
44
|
+
but per-owner lookups are sufficient (we only check owners of pending paid
|
|
45
|
+
items). No api-gate change is required.
|
|
46
|
+
- DB migrations are an ordered list in
|
|
47
|
+
[`catalog/db.py`](../kryten_webqueue/catalog/db.py); latest is **v9**, so new
|
|
48
|
+
migrations begin at **v10**.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 1. Feature 1 — Presence-based cancel/refund
|
|
53
|
+
|
|
54
|
+
### 1.1 Resolved decisions
|
|
55
|
+
|
|
56
|
+
| Question | Decision |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| Which items | **Only paid (pay-to-play) items that have not started playing.** Free/scheduled items are left alone. |
|
|
59
|
+
| Currently-playing item | **Never** cancelled, even if its owner vanished. |
|
|
60
|
+
| Trigger | **Leave OR AFK**, both enabled by default. |
|
|
61
|
+
| Grace period | **Configurable** (default 60s) before acting; re-check after grace and keep the item if the owner returned / is no longer AFK. |
|
|
62
|
+
|
|
63
|
+
### 1.2 Config additions (`Config`)
|
|
64
|
+
|
|
65
|
+
```jsonc
|
|
66
|
+
"presence_refund": {
|
|
67
|
+
"enabled": true,
|
|
68
|
+
"on_leave": true,
|
|
69
|
+
"on_afk": false, // default off until the Robot setAFK fix ships (O1)
|
|
70
|
+
"grace_seconds": 60,
|
|
71
|
+
"check_interval_seconds": 15 // how often to evaluate owners (>= poll interval)
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
(Modeled as a nested `PresenceRefundConfig(BaseModel)` like `FetchUrlsConfig`.)
|
|
76
|
+
|
|
77
|
+
### 1.3 Component: `PresenceRefundMonitor`
|
|
78
|
+
|
|
79
|
+
New module `kryten_webqueue/queue/presence.py`, started in `app.py` lifespan
|
|
80
|
+
(like `StatePoller` / `PlaylistScheduler`). It owns its own loop on
|
|
81
|
+
`check_interval_seconds` (decoupled from the 3s state poll to avoid hammering
|
|
82
|
+
`get_user`).
|
|
83
|
+
|
|
84
|
+
Per cycle:
|
|
85
|
+
|
|
86
|
+
1. Read pending paid items from the shadow: `is_pay = True` and **not** the
|
|
87
|
+
currently-playing uid (`_now_playing_uid`). Collect the distinct set of owner
|
|
88
|
+
usernames (`paid_by`).
|
|
89
|
+
2. For each distinct owner, call `api_gate.get_user(username)` once and classify:
|
|
90
|
+
- `online is False` (not in channel) → **gone** (if `on_leave`).
|
|
91
|
+
- online but `meta.afk is True` → **afk** (if `on_afk`).
|
|
92
|
+
- otherwise → **present** → clear any tracked "missing since".
|
|
93
|
+
3. Maintain an in-memory `missing_since: dict[username -> (timestamp, reason)]`.
|
|
94
|
+
- First time an owner is seen gone/afk → record `missing_since[user] = now`.
|
|
95
|
+
- When `now - missing_since[user] >= grace_seconds` → act on **all** of that
|
|
96
|
+
owner's pending paid items.
|
|
97
|
+
- If the owner becomes present again before grace elapses → drop the entry
|
|
98
|
+
(the item is kept).
|
|
99
|
+
4. **Act** on an item = `refund_item(uid, reason="owner_left" | "owner_afk")`
|
|
100
|
+
then `api_gate.playlist_delete(uid)` to remove it from CyTube, then remove it
|
|
101
|
+
from the shadow. Also remove any **Viewer's Choice lead-in promo** associated
|
|
102
|
+
with that uid (see §2.6).
|
|
103
|
+
5. Broadcast a `queue_state` update and (optionally) a chat / WS notice.
|
|
104
|
+
|
|
105
|
+
### 1.4 Edge cases
|
|
106
|
+
|
|
107
|
+
- **Owner returns after cancel**: not re-queued (cancellation is final). The
|
|
108
|
+
refund makes them whole; they can re-queue.
|
|
109
|
+
- **Multiple items, same owner**: all pending paid items for that owner are
|
|
110
|
+
cancelled together once grace elapses.
|
|
111
|
+
- **Item starts playing during grace**: once it is the now-playing item it is
|
|
112
|
+
exempt; only still-pending items are cancelled.
|
|
113
|
+
- **`get_user` timeout / error**: treat as *inconclusive* (do not start the
|
|
114
|
+
grace clock); avoids false cancels on a transient robot/NATS hiccup.
|
|
115
|
+
- **AFK semantics**: relies on `meta.afk` from the robot's userlist. **This is
|
|
116
|
+
currently stale** — the Robot does not handle CyTube's `setAFK` event, so
|
|
117
|
+
`meta.afk` only reflects join-time state. A Robot fix is a prerequisite for the
|
|
118
|
+
AFK trigger; see Open item **O1** (resolved) for the exact change. Until that
|
|
119
|
+
ships, keep `on_afk` defaulted off; the leave trigger is unaffected.
|
|
120
|
+
|
|
121
|
+
### 1.5 Tests
|
|
122
|
+
|
|
123
|
+
- Owner goes offline → after grace, paid item refunded + removed; free item left.
|
|
124
|
+
- Owner AFK then returns within grace → item retained, no refund.
|
|
125
|
+
- Now-playing item's owner offline → item retained.
|
|
126
|
+
- `get_user` raises → no action; next cycle with a real signal acts.
|
|
127
|
+
- Two pending items, one owner → both cancelled in one grace window.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 2. Feature 2 — Promo insertion system
|
|
132
|
+
|
|
133
|
+
### 2.1 Promo types (5)
|
|
134
|
+
|
|
135
|
+
| # | `promo_type` | Trigger | Selection |
|
|
136
|
+
| --- | --- | --- | --- |
|
|
137
|
+
| 1 | `channel_identity` | general cadence | per-type config |
|
|
138
|
+
| 2 | `event` | general cadence | per-type config |
|
|
139
|
+
| 3 | `mod_shoutout` (mod hat-tips) | general cadence | per-type config |
|
|
140
|
+
| 4 | `feature_presentation` | a **mutable-playlist** movie (`duration_sec >= 3600`) is the next item | random from pool |
|
|
141
|
+
| 5 | `viewers_choice` | a **pay-to-play** item is the next item (**any length**) | random from pool |
|
|
142
|
+
|
|
143
|
+
Types 1–3 are the "general" promos inserted on a cadence between content.
|
|
144
|
+
Types 4–5 are "lead-ins" attached immediately before a specific item.
|
|
145
|
+
|
|
146
|
+
### 2.2 Resolved decisions
|
|
147
|
+
|
|
148
|
+
| Question | Decision |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| Promo storage | **Reserved `saved_playlists` tagged with a `promo_type`.** Reuse the existing playlist editor; items in the playlist are the promo clips. |
|
|
151
|
+
| Promo visibility | Hidden from public browse/search **and** excluded from pay-to-play (same treatment as immutable). |
|
|
152
|
+
| Insertion timing | **Just-in-time via the poller** as playback advances (handles looping, pay items, and movies uniformly). Viewer's Choice is inserted deterministically at pay-insertion time — see §2.5. |
|
|
153
|
+
| Movie threshold | `duration_sec >= 3600` (>= 60:00). |
|
|
154
|
+
| Feature Presentation vs Viewer's Choice | A **paid movie** is "paid" first → gets **Viewer's Choice only**, never Feature Presentation. |
|
|
155
|
+
| Stacked promos | A movie due both a cadence general promo **and** an FP/VC lead-in plays: **general promo first, then the FP/VC lead-in immediately before the item.** Order: `[general][FP|VC][content]`. |
|
|
156
|
+
| Lead-in cost | Lead-ins are **free** system inserts. If the paid item is later cancelled/refunded, its lead-in promo is removed too. |
|
|
157
|
+
| Scope | Promos are inserted **only into mutable content** — never during a running immutable scheduled event (`active_schedule.is_immutable`). |
|
|
158
|
+
|
|
159
|
+
### 2.3 General promo cadence / selection config
|
|
160
|
+
|
|
161
|
+
```jsonc
|
|
162
|
+
"promos": {
|
|
163
|
+
"enabled": true,
|
|
164
|
+
"movie_threshold_seconds": 3600,
|
|
165
|
+
"general": {
|
|
166
|
+
"every_n_items": 4, // insert a general promo every N content items
|
|
167
|
+
"every_m_minutes": 20, // ...or roughly every M minutes, whichever first
|
|
168
|
+
"no_repeat": true // don't play the same promo clip twice in a row
|
|
169
|
+
},
|
|
170
|
+
"types": {
|
|
171
|
+
"channel_identity": { "enabled": true, "order": "random", "weight": 3 },
|
|
172
|
+
"event": { "enabled": true, "order": "random", "weight": 2 },
|
|
173
|
+
"mod_shoutout": { "enabled": true, "order": "sequential", "weight": 1 },
|
|
174
|
+
"feature_presentation": { "enabled": true, "order": "random" },
|
|
175
|
+
"viewers_choice": { "enabled": true, "order": "random" }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
- `order`: `random` (uniform over the pool) or `sequential` (rotate through the
|
|
181
|
+
pool in stored order, resuming where it left off).
|
|
182
|
+
- `weight`: relative frequency among the **general** types when a cadence slot
|
|
183
|
+
fires (the type is chosen by weighted random; the clip within the type is then
|
|
184
|
+
chosen by that type's `order`).
|
|
185
|
+
- `no_repeat`: track the last-played clip token per pool and reselect if a draw
|
|
186
|
+
repeats it (skipped for single-item pools).
|
|
187
|
+
- Per-type `enabled` lets a type be turned off without deleting its playlist.
|
|
188
|
+
|
|
189
|
+
### 2.4 Data model changes
|
|
190
|
+
|
|
191
|
+
**Migration v10** — tag promo playlists:
|
|
192
|
+
```sql
|
|
193
|
+
ALTER TABLE saved_playlists ADD COLUMN promo_type TEXT; -- NULL = normal playlist
|
|
194
|
+
CREATE INDEX IF NOT EXISTS idx_saved_playlists_promo ON saved_playlists(promo_type);
|
|
195
|
+
```
|
|
196
|
+
A playlist with a non-NULL `promo_type` is a promo pool. Treated like
|
|
197
|
+
`is_immutable` for visibility/pay-exclusion (hidden from browse/search, not
|
|
198
|
+
pay-queueable). One designated playlist per type is expected; if several share a
|
|
199
|
+
type, their items are unioned into that type's pool.
|
|
200
|
+
|
|
201
|
+
**Migration v11** — annotate live promo items in the shadow:
|
|
202
|
+
```sql
|
|
203
|
+
ALTER TABLE queue_shadow ADD COLUMN is_promo BOOLEAN NOT NULL DEFAULT 0;
|
|
204
|
+
ALTER TABLE queue_shadow ADD COLUMN promo_type TEXT;
|
|
205
|
+
ALTER TABLE queue_shadow ADD COLUMN lead_in_for_uid INTEGER; -- FP/VC: the content uid this promo precedes
|
|
206
|
+
```
|
|
207
|
+
`QueueShadow` items gain matching keys (`is_promo`, `promo_type`,
|
|
208
|
+
`lead_in_for_uid`). `apply_poll_result` preserves these like other local
|
|
209
|
+
metadata. Externally-added items default to non-promo.
|
|
210
|
+
|
|
211
|
+
### 2.5 Component: `PromoDirector`
|
|
212
|
+
|
|
213
|
+
New module `kryten_webqueue/promos/director.py`, started in `app.py` lifespan.
|
|
214
|
+
It is driven by the poll cycle (subscribes to the same reconcile, or runs as a
|
|
215
|
+
hook at the end of `apply_poll_result`). It holds in-memory cadence state:
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
content_since_last_general: int
|
|
219
|
+
last_general_at: datetime
|
|
220
|
+
last_clip_token: dict[promo_type -> str] # for no_repeat
|
|
221
|
+
seq_index: dict[promo_type -> int] # for sequential order
|
|
222
|
+
inserted_general_before_uid: set[int] # idempotency for general slot
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**No-op conditions**: `promos.enabled` is false, OR `active_schedule.is_immutable`
|
|
226
|
+
is true (running curated event).
|
|
227
|
+
|
|
228
|
+
Per cycle:
|
|
229
|
+
|
|
230
|
+
1. **Detect advance**: if now-playing changed since last cycle and the item that
|
|
231
|
+
just finished was **content** (`not is_promo`), increment
|
|
232
|
+
`content_since_last_general` and reset per-slot idempotency markers that have
|
|
233
|
+
now played. Promo items that finished do **not** count as content.
|
|
234
|
+
2. **Find the next content item**: first non-promo item after now-playing in play
|
|
235
|
+
order (skip any promos already inserted).
|
|
236
|
+
3. **Decide the lead-in for that item** (mutually exclusive):
|
|
237
|
+
- next item `is_pay` → **Viewer's Choice** (any length).
|
|
238
|
+
- else next item is a movie (`duration_sec >= movie_threshold_seconds`) →
|
|
239
|
+
**Feature Presentation**.
|
|
240
|
+
- else → no lead-in.
|
|
241
|
+
Ensure exactly one lead-in promo with `lead_in_for_uid == target_uid` exists
|
|
242
|
+
immediately before the target; if absent and the pool is non-empty/enabled,
|
|
243
|
+
insert one.
|
|
244
|
+
4. **Decide a general promo** (cadence): if general enabled AND
|
|
245
|
+
(`content_since_last_general >= every_n_items` OR
|
|
246
|
+
`now - last_general_at >= every_m_minutes`) AND we haven't already inserted a
|
|
247
|
+
general promo for this target (`target_uid not in inserted_general_before_uid`):
|
|
248
|
+
pick a type by weight, a clip by that type's order/no-repeat, and insert it.
|
|
249
|
+
Reset `content_since_last_general = 0`, set `last_general_at = now`, add
|
|
250
|
+
`target_uid` to `inserted_general_before_uid`.
|
|
251
|
+
5. **Placement / order**: both promos go **before** the target content item, with
|
|
252
|
+
the general promo before the FP/VC lead-in →
|
|
253
|
+
`[general][FP|VC][target]`. Implemented by `playlist_add(temp=True)` then
|
|
254
|
+
`playlist_move` to the correct slot (reuse the throttled add helper from
|
|
255
|
+
v0.9.13 to avoid 422s). Mark inserted items in the shadow with `is_promo=1`,
|
|
256
|
+
`promo_type`, and (for lead-ins) `lead_in_for_uid`.
|
|
257
|
+
6. **Temp items**: promos are added as CyTube **temp** items so they
|
|
258
|
+
auto-remove after playing and never accumulate across loops.
|
|
259
|
+
|
|
260
|
+
**Viewer's Choice at pay-insertion (determinism)**: because a "play next" paid
|
|
261
|
+
item can begin before the next 3s poll, the Viewer's Choice lead-in is inserted
|
|
262
|
+
**synchronously inside `insert_pay_queue` / `insert_pay_playnext`** right after
|
|
263
|
+
the paid item is positioned — placing the VC promo immediately before the new
|
|
264
|
+
paid uid and tagging it `lead_in_for_uid = <paid uid>`. The poller path remains
|
|
265
|
+
as a safety net / for playlist-sourced items. This is still "just-in-time", not
|
|
266
|
+
load-time. (FP and general promos stay purely poller-driven.)
|
|
267
|
+
|
|
268
|
+
### 2.6 Removal / refund interactions
|
|
269
|
+
|
|
270
|
+
- `refund_item` callers (presence monitor §1.3, and the existing
|
|
271
|
+
`move_failed` / schedule-displaced paths) must, after deleting a paid uid,
|
|
272
|
+
delete any shadow item where `lead_in_for_uid == uid` from CyTube and the
|
|
273
|
+
shadow (the orphaned Viewer's Choice lead-in).
|
|
274
|
+
- Add a small helper `remove_lead_in_for(uid)` in the promo module and call it
|
|
275
|
+
from the cancel/refund paths.
|
|
276
|
+
- When a promo item is observed gone from a poll (it played out as a temp item),
|
|
277
|
+
normal shadow reconciliation removes it; cadence counters are unaffected
|
|
278
|
+
because promos don't count as content.
|
|
279
|
+
|
|
280
|
+
### 2.7 Admin UI
|
|
281
|
+
|
|
282
|
+
- **Promo pools page** (or a section on the existing Playlists page): designate a
|
|
283
|
+
saved playlist as a promo pool by choosing its `promo_type` (dropdown:
|
|
284
|
+
none / channel_identity / event / mod_shoutout / feature_presentation /
|
|
285
|
+
viewers_choice). Promo pools are visually flagged and excluded from the public
|
|
286
|
+
catalog like immutable playlists.
|
|
287
|
+
- **Promo settings panel**: edit the `promos` config (global enable, cadence
|
|
288
|
+
`every_n_items` / `every_m_minutes`, `no_repeat`, per-type enable / order /
|
|
289
|
+
weight, `movie_threshold_seconds`). If config is file-only today, expose
|
|
290
|
+
read-only display first and make it editable in a follow-up.
|
|
291
|
+
- Live queue view: render promo items distinctly (badge by `promo_type`).
|
|
292
|
+
|
|
293
|
+
### 2.8 Edge cases
|
|
294
|
+
|
|
295
|
+
- **Empty / disabled pool**: if the chosen type's pool is empty or disabled, skip
|
|
296
|
+
that insertion (no error; for general, try the next weighted type or skip the
|
|
297
|
+
slot).
|
|
298
|
+
- **Back-to-back promos**: never insert a general promo before another promo;
|
|
299
|
+
scanning always targets the next **content** item.
|
|
300
|
+
- **Movie that is also paid**: paid wins → Viewer's Choice only (no FP).
|
|
301
|
+
- **Looping queue**: temp promos disappear after playing; next loop re-inserts by
|
|
302
|
+
cadence, so promos don't pile up.
|
|
303
|
+
- **Immutable events**: director is a no-op; curated events play exactly as built.
|
|
304
|
+
- **Now-playing is a movie at startup**: no retroactive lead-in (can't precede a
|
|
305
|
+
playing item); applies from the next qualifying upcoming item.
|
|
306
|
+
- **Pre-fire lock / fallback**: fallback (mutable) content is eligible for
|
|
307
|
+
promos; the immutable event body is not.
|
|
308
|
+
|
|
309
|
+
### 2.9 Tests
|
|
310
|
+
|
|
311
|
+
- Cadence: after N content items, exactly one general promo inserted; counter
|
|
312
|
+
resets; not re-inserted on the next poll for the same slot.
|
|
313
|
+
- Minutes cadence fires independently of item count.
|
|
314
|
+
- Weighted type selection over many draws approximates configured weights.
|
|
315
|
+
- `no_repeat` never selects the same clip twice consecutively (pool size >= 2).
|
|
316
|
+
- `sequential` rotates in stored order and resumes.
|
|
317
|
+
- Upcoming mutable movie (>=3600s) → FP lead-in immediately before it.
|
|
318
|
+
- Upcoming paid item (short) → Viewer's Choice lead-in; paid movie → VC, not FP.
|
|
319
|
+
- Stacked order is `[general][FP|VC][content]`.
|
|
320
|
+
- Cancelling a paid item removes its VC lead-in.
|
|
321
|
+
- Director no-ops during an immutable scheduled event.
|
|
322
|
+
- Promo pools hidden from browse/search and rejected by pay-to-play.
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## 3. Cross-cutting
|
|
327
|
+
|
|
328
|
+
- **Startup wiring** (`app.py` lifespan): construct and start
|
|
329
|
+
`PresenceRefundMonitor` and `PromoDirector` after the shadow/poller/scheduler,
|
|
330
|
+
passing `api_gate`, `db`, `shadow`, `ws_manager`, and the new config blocks;
|
|
331
|
+
stop them on shutdown.
|
|
332
|
+
- **Reuse the throttled add helper** (`playlists/bulk_add.py`, v0.9.13) for all
|
|
333
|
+
promo inserts to avoid transient CyTube `queueFail`/422s.
|
|
334
|
+
- **Config example**: add `presence_refund` and `promos` blocks to
|
|
335
|
+
`config.example.json` with the defaults above.
|
|
336
|
+
- **CHANGELOG + version**: ship as a minor bump (e.g. `0.10.0`) given the new
|
|
337
|
+
subsystems; update `CHANGELOG.md`.
|
|
338
|
+
- **Docs**: link this plan from `docs/IMPLEMENTATION_SPEC.md` once implemented.
|
|
339
|
+
|
|
340
|
+
## 4. Suggested implementation order
|
|
341
|
+
|
|
342
|
+
1. Migrations v10/v11 + shadow field plumbing (no behavior change).
|
|
343
|
+
2. `PresenceRefundConfig` + `PresenceRefundMonitor` + tests (self-contained).
|
|
344
|
+
3. Promo data model: `promo_type` on playlists, visibility/pay exclusion, admin
|
|
345
|
+
designation UI.
|
|
346
|
+
4. `PromoConfig` + `PromoDirector` general cadence (types 1–3) + tests.
|
|
347
|
+
5. Feature Presentation lead-in (type 4) + tests.
|
|
348
|
+
6. Viewer's Choice (type 5): synchronous pay-insertion hook + cancel cleanup +
|
|
349
|
+
tests.
|
|
350
|
+
7. Admin promo settings panel + live-queue badges.
|
|
351
|
+
8. Config example, CHANGELOG, version bump, release.
|
|
352
|
+
|
|
353
|
+
## 5. Open items / assumptions to confirm during build
|
|
354
|
+
|
|
355
|
+
- **O1 — AFK source**: confirm `get_user().meta.afk` is populated by the robot's
|
|
356
|
+
userlist in practice. If not, options: (a) add a userlist/AFK passthrough in
|
|
357
|
+
api-gate, or (b) ship with `on_afk` defaulting off until verified.
|
|
358
|
+
|
|
359
|
+
**RESOLVED (2026-06-13, code-traced):**
|
|
360
|
+
- **Leave detection works as planned.** CyTube `userLeave` →
|
|
361
|
+
`state_manager.remove_user()` → user drops from the userlist →
|
|
362
|
+
`get_user()` returns `None` → api-gate `state/user` returns
|
|
363
|
+
`{"online": False}`. No change needed for the leave path.
|
|
364
|
+
- **AFK detection is currently broken — needs a Robot fix.** `get_user()`
|
|
365
|
+
returns the raw stored CyTube user object and the Robot *expects* a
|
|
366
|
+
`meta.afk` field (it already reads it for user-counting in
|
|
367
|
+
[`state_manager.py`](../../Kryten-Robot/kryten/state_manager.py) ~L119).
|
|
368
|
+
**However**, the Robot only refreshes user `meta` on the `userlist` and
|
|
369
|
+
`addUser` events ([`__main__.py`](../../Kryten-Robot/kryten/__main__.py)
|
|
370
|
+
~L341–L348). CyTube's **`setAFK`** event (`{name, afk}`) is **not** in the
|
|
371
|
+
registered `state_events` list and is never dispatched, so `meta.afk` only
|
|
372
|
+
reflects the user's AFK state *at join time* and goes stale afterward.
|
|
373
|
+
- **Required Robot change (prerequisite for `on_afk`)**: handle the `setAFK`
|
|
374
|
+
event. Add `"setAFK"` to the `state_events` list and a dispatch branch that
|
|
375
|
+
merges `afk` into the stored user's `meta`, e.g.:
|
|
376
|
+
```python
|
|
377
|
+
elif event_name == "setAFK":
|
|
378
|
+
name = payload.get("name")
|
|
379
|
+
if name is not None:
|
|
380
|
+
existing = state_manager.get_user(name) or {"name": name}
|
|
381
|
+
meta = {**existing.get("meta", {}), "afk": bool(payload.get("afk"))}
|
|
382
|
+
await state_manager.update_user({**existing, "name": name, "meta": meta})
|
|
383
|
+
```
|
|
384
|
+
(Confirm CyTube's `setAFK` payload shape against the live socket;
|
|
385
|
+
historically `{name, afk}`.) Ship this Robot fix first, or ship webqueue with
|
|
386
|
+
`on_afk` defaulting **off** and flip it on once the Robot change is deployed.
|
|
387
|
+
- No api-gate change is needed for either path; `state/user` already passes the
|
|
388
|
+
stored `meta` through.
|
|
389
|
+
|
|
390
|
+
**DONE (Robot v1.10.0, 2026-06-13):** the Robot now handles `setAFK` via
|
|
391
|
+
`StateManager.set_user_afk()` (merges into `meta` in place, preserves other
|
|
392
|
+
fields, skips redundant KV writes) and registers the event in `state_events`.
|
|
393
|
+
Covered by `tests/test_state_manager_afk.py`. Once Robot v1.10.0 is deployed,
|
|
394
|
+
`on_afk` can be safely defaulted **on** in webqueue's presence-refund config.
|
|
395
|
+
|
|
396
|
+
**DONE (webqueue v0.13.0):** `presence_refund.on_afk` now defaults **on** (the
|
|
397
|
+
`PresenceRefundConfig` default and `config.example.json` both flipped to
|
|
398
|
+
`true`). Set it back off only if running against a Robot older than v1.10.0.
|
|
399
|
+
- **O2 — Config editability**: is runtime editing of `promos` config in-scope, or
|
|
400
|
+
is file-config + restart acceptable for the first cut? (Plan assumes file
|
|
401
|
+
config first, editable panel as a follow-up.)
|
|
402
|
+
|
|
403
|
+
**RESOLVED (v0.13.0):** the editable panel shipped. The Promos admin settings
|
|
404
|
+
panel now edits the `promos` config (system enable, movie threshold, general
|
|
405
|
+
cadence, per-type enable/order/weight) via `PUT /admin/promos/config`, which
|
|
406
|
+
validates against `PromoConfig`, persists with `Config.save()` (atomic write
|
|
407
|
+
back to the loaded config file), and hot-applies via
|
|
408
|
+
`PromoDirector.update_config()` — no restart required.
|
|
409
|
+
- **O3 — Single vs multiple pools per type**: plan supports multiple playlists of
|
|
410
|
+
the same `promo_type` (unioned). Confirm one-per-type is acceptable in the UI.
|
|
411
|
+
|
|
412
|
+
**RESOLVED (v0.13.0):** **multiple pools per type are intentionally allowed.**
|
|
413
|
+
Clips from every playlist sharing a `promo_type` are unioned into that type's
|
|
414
|
+
pool (`get_promo_pool_items`), and the admin UI permits tagging more than one
|
|
415
|
+
playlist with the same type. No enforcement was added — this is the accepted
|
|
416
|
+
product behaviour, not a bug.
|
|
417
|
+
- **O4 — Notifications**: should a cancelled-on-disappear item post a chat/PM
|
|
418
|
+
notice, or refund silently? (Plan: silent refund + WS state update; easy to add
|
|
419
|
+
a notice.)
|
|
420
|
+
|
|
421
|
+
**RESOLVED (v0.13.0):** a PM is now sent. On a presence-based cancel the owner
|
|
422
|
+
receives a PM ("… cancelled and refunded because you left the channel / you
|
|
423
|
+
went AFK"), gated by `presence_refund.notify_user` (default on) and best-effort
|
|
424
|
+
so a failed PM never blocks the refund.
|
|
@@ -16,6 +16,8 @@ from .jobs import JobManager
|
|
|
16
16
|
from .catalog.images import CoverArtResolver
|
|
17
17
|
from .queue.shadow import QueueShadow
|
|
18
18
|
from .queue.poller import StatePoller
|
|
19
|
+
from .queue.presence import PresenceRefundMonitor
|
|
20
|
+
from .promos.director import PromoDirector
|
|
19
21
|
from .ws.manager import WebSocketManager
|
|
20
22
|
from .playlists.scheduler import PlaylistScheduler
|
|
21
23
|
from .auth.rate_limit import RateLimiter
|
|
@@ -29,6 +31,7 @@ from .routes.admin_schedules import router as admin_schedules_router
|
|
|
29
31
|
from .routes.admin_queue import router as admin_queue_router
|
|
30
32
|
from .routes.admin_jobs import router as admin_jobs_router
|
|
31
33
|
from .routes.admin_catalog import router as admin_catalog_router
|
|
34
|
+
from .routes.admin_promos import router as admin_promos_router
|
|
32
35
|
from .routes.pages import router as pages_router
|
|
33
36
|
from .ws.handler import router as ws_router
|
|
34
37
|
|
|
@@ -116,6 +119,14 @@ async def lifespan(app: FastAPI):
|
|
|
116
119
|
await shadow.load_from_db()
|
|
117
120
|
app.state.shadow = shadow
|
|
118
121
|
|
|
122
|
+
# Promo director (poller-driven; also used synchronously by the pay path)
|
|
123
|
+
promo_director = PromoDirector(
|
|
124
|
+
api_gate=api_gate, db=db, shadow=shadow, config=config.promos,
|
|
125
|
+
add_delay_sec=config.playlist_bulk_add_delay_sec,
|
|
126
|
+
add_max_retries=config.playlist_bulk_add_max_retries,
|
|
127
|
+
)
|
|
128
|
+
app.state.promo_director = promo_director
|
|
129
|
+
|
|
119
130
|
# State poller
|
|
120
131
|
poller = StatePoller(
|
|
121
132
|
api_gate=api_gate,
|
|
@@ -123,6 +134,7 @@ async def lifespan(app: FastAPI):
|
|
|
123
134
|
ws_manager=ws_manager,
|
|
124
135
|
db=db,
|
|
125
136
|
interval=config.state_poll_interval_sec,
|
|
137
|
+
promo_director=promo_director,
|
|
126
138
|
)
|
|
127
139
|
await poller.start()
|
|
128
140
|
app.state.poller = poller
|
|
@@ -139,6 +151,14 @@ async def lifespan(app: FastAPI):
|
|
|
139
151
|
await scheduler.start()
|
|
140
152
|
app.state.scheduler = scheduler
|
|
141
153
|
|
|
154
|
+
# Presence-based cancel/refund monitor
|
|
155
|
+
presence_monitor = PresenceRefundMonitor(
|
|
156
|
+
api_gate=api_gate, shadow=shadow, db=db, ws_manager=ws_manager,
|
|
157
|
+
config=config.presence_refund,
|
|
158
|
+
)
|
|
159
|
+
await presence_monitor.start()
|
|
160
|
+
app.state.presence_monitor = presence_monitor
|
|
161
|
+
|
|
142
162
|
# Background workers
|
|
143
163
|
async def _catalog_sync_loop():
|
|
144
164
|
interval = config.catalog_sync_interval_hours * 3600
|
|
@@ -195,6 +215,7 @@ async def lifespan(app: FastAPI):
|
|
|
195
215
|
task.cancel()
|
|
196
216
|
await poller.stop()
|
|
197
217
|
await scheduler.stop()
|
|
218
|
+
await presence_monitor.stop()
|
|
198
219
|
await catalog_sync.close()
|
|
199
220
|
await cover_art.close()
|
|
200
221
|
await api_gate.close()
|
|
@@ -221,6 +242,7 @@ def create_app(config: Config) -> FastAPI:
|
|
|
221
242
|
app.include_router(admin_queue_router)
|
|
222
243
|
app.include_router(admin_jobs_router)
|
|
223
244
|
app.include_router(admin_catalog_router)
|
|
245
|
+
app.include_router(admin_promos_router)
|
|
224
246
|
app.include_router(ws_router)
|
|
225
247
|
|
|
226
248
|
# Health check
|