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.
Files changed (101) hide show
  1. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/CHANGELOG.md +49 -0
  2. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/config.example.json +26 -0
  4. kryten_webqueue-0.14.0/docs/PLAN_PRESENCE_AND_PROMOS.md +424 -0
  5. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/app.py +22 -0
  6. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/db.py +57 -15
  7. kryten_webqueue-0.14.0/kryten_webqueue/config.py +170 -0
  8. kryten_webqueue-0.14.0/kryten_webqueue/promos/__init__.py +12 -0
  9. kryten_webqueue-0.14.0/kryten_webqueue/promos/director.py +372 -0
  10. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/ordering.py +21 -0
  11. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/poller.py +8 -1
  12. kryten_webqueue-0.14.0/kryten_webqueue/queue/presence.py +203 -0
  13. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/shadow.py +3 -0
  14. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_playlists.py +16 -0
  15. kryten_webqueue-0.14.0/kryten_webqueue/routes/admin_promos.py +64 -0
  16. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/pages.py +8 -0
  17. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/queue.py +2 -0
  18. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/static/css/main.css +25 -0
  19. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/index.html +1 -0
  20. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/playlists.html +3 -1
  21. kryten_webqueue-0.14.0/kryten_webqueue/templates/admin/promos.html +212 -0
  22. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/queue/index.html +11 -1
  23. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/pyproject.toml +1 -1
  24. kryten_webqueue-0.14.0/tests/test_config_persistence.py +54 -0
  25. kryten_webqueue-0.14.0/tests/test_presence_refund.py +268 -0
  26. kryten_webqueue-0.14.0/tests/test_promo_director.py +376 -0
  27. kryten_webqueue-0.14.0/tests/test_promo_pool_exclusion.py +84 -0
  28. kryten_webqueue-0.9.13/kryten_webqueue/config.py +0 -76
  29. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/.github/workflows/python-publish.yml +0 -0
  30. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/.github/workflows/release.yml +0 -0
  31. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/.gitignore +0 -0
  32. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/README.md +0 -0
  33. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/deploy/kryten-webqueue.service +0 -0
  34. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/deploy/nginx-queue.conf +0 -0
  35. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  36. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/IMPL_API_GATE.md +0 -0
  37. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/IMPL_ECONOMY.md +0 -0
  38. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  39. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/IMPL_ROBOT.md +0 -0
  40. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/PRE_PLAN_GAPS.md +0 -0
  41. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/PRODUCT_PLAN.md +0 -0
  42. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  43. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/__init__.py +0 -0
  44. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/__main__.py +0 -0
  45. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  46. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/api_gate/client.py +0 -0
  47. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/__init__.py +0 -0
  48. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/otp.py +0 -0
  49. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  50. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/session.py +0 -0
  51. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/__init__.py +0 -0
  52. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/images.py +0 -0
  53. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  54. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/sync.py +0 -0
  55. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/__init__.py +0 -0
  56. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  57. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  58. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  59. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  60. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  61. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  62. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  63. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  64. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/__init__.py +0 -0
  65. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  66. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/manager.py +0 -0
  67. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/tasks.py +0 -0
  68. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/__init__.py +0 -0
  69. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  70. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/fire.py +0 -0
  71. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/importer.py +0 -0
  72. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  73. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/__init__.py +0 -0
  74. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/__init__.py +0 -0
  75. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  76. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  77. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  78. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  79. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/auth.py +0 -0
  80. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/catalog.py +0 -0
  81. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/user.py +0 -0
  82. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/static/js/main.js +0 -0
  83. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  84. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  85. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/auth/login.html +0 -0
  86. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/base.html +0 -0
  87. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  88. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  89. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  90. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  91. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/ws/__init__.py +0 -0
  92. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/ws/handler.py +0 -0
  93. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/kryten_webqueue/ws/manager.py +0 -0
  94. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/__init__.py +0 -0
  95. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/test_fetchurls_sharepoint.py +0 -0
  96. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/test_phase1.py +0 -0
  97. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/test_phase2_jobs.py +0 -0
  98. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/test_phase3_jobs.py +0 -0
  99. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/test_phase4_live_fixes.py +0 -0
  100. {kryten_webqueue-0.9.13 → kryten_webqueue-0.14.0}/tests/test_playlist_import.py +0 -0
  101. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.9.13
3
+ Version: 0.14.0
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -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