kryten-webqueue 0.10.0__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 (100) hide show
  1. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/CHANGELOG.md +42 -0
  2. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/config.example.json +20 -2
  4. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +22 -0
  5. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/app.py +12 -0
  6. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/db.py +57 -15
  7. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/config.py +77 -6
  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.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/ordering.py +21 -0
  11. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/poller.py +8 -1
  12. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/presence.py +26 -0
  13. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/shadow.py +3 -0
  14. {kryten_webqueue-0.10.0 → 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.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/pages.py +8 -0
  17. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/queue.py +2 -0
  18. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/static/css/main.css +25 -0
  19. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/index.html +1 -0
  20. {kryten_webqueue-0.10.0 → 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.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/queue/index.html +11 -1
  23. {kryten_webqueue-0.10.0 → 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.10.0 → kryten_webqueue-0.14.0}/tests/test_presence_refund.py +34 -1
  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.10.0 → kryten_webqueue-0.14.0}/.github/workflows/python-publish.yml +0 -0
  29. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/.github/workflows/release.yml +0 -0
  30. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/.gitignore +0 -0
  31. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/README.md +0 -0
  32. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/deploy/kryten-webqueue.service +0 -0
  33. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/deploy/nginx-queue.conf +0 -0
  34. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  35. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/IMPL_API_GATE.md +0 -0
  36. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/IMPL_ECONOMY.md +0 -0
  37. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  38. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/IMPL_ROBOT.md +0 -0
  39. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/PRE_PLAN_GAPS.md +0 -0
  40. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/PRODUCT_PLAN.md +0 -0
  41. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  42. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/__init__.py +0 -0
  43. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/__main__.py +0 -0
  44. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  45. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/api_gate/client.py +0 -0
  46. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/__init__.py +0 -0
  47. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/otp.py +0 -0
  48. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  49. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/session.py +0 -0
  50. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/__init__.py +0 -0
  51. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/images.py +0 -0
  52. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  53. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/sync.py +0 -0
  54. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/__init__.py +0 -0
  55. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  56. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  57. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  58. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  59. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  60. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  61. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  62. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  63. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/__init__.py +0 -0
  64. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  65. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/manager.py +0 -0
  66. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/tasks.py +0 -0
  67. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/__init__.py +0 -0
  68. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  69. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/fire.py +0 -0
  70. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/importer.py +0 -0
  71. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  72. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/__init__.py +0 -0
  73. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/__init__.py +0 -0
  74. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  75. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  76. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  77. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  78. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/auth.py +0 -0
  79. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/catalog.py +0 -0
  80. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/user.py +0 -0
  81. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/static/js/main.js +0 -0
  82. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  83. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  84. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/auth/login.html +0 -0
  85. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/base.html +0 -0
  86. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  87. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  88. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  89. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  90. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/ws/__init__.py +0 -0
  91. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/ws/handler.py +0 -0
  92. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/ws/manager.py +0 -0
  93. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/__init__.py +0 -0
  94. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/test_fetchurls_sharepoint.py +0 -0
  95. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/test_phase1.py +0 -0
  96. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/test_phase2_jobs.py +0 -0
  97. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/test_phase3_jobs.py +0 -0
  98. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/test_phase4_live_fixes.py +0 -0
  99. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/test_playlist_import.py +0 -0
  100. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/test_queue_announce.py +0 -0
@@ -6,6 +6,48 @@ 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
+
9
51
  ## [0.10.0] — 2026-06-13
10
52
 
11
53
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.10.0
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
@@ -26,9 +26,27 @@
26
26
  "presence_refund": {
27
27
  "enabled": true,
28
28
  "on_leave": true,
29
- "on_afk": false,
29
+ "on_afk": true,
30
30
  "grace_seconds": 60.0,
31
- "check_interval_seconds": 15.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
+ }
32
50
  },
33
51
 
34
52
  "db_path": "/var/lib/kryten-webqueue/webqueue.db",
@@ -392,11 +392,33 @@ load-time. (FP and general promos stay purely poller-driven.)
392
392
  fields, skips redundant KV writes) and registers the event in `state_events`.
393
393
  Covered by `tests/test_state_manager_afk.py`. Once Robot v1.10.0 is deployed,
394
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.
395
399
  - **O2 — Config editability**: is runtime editing of `promos` config in-scope, or
396
400
  is file-config + restart acceptable for the first cut? (Plan assumes file
397
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.
398
409
  - **O3 — Single vs multiple pools per type**: plan supports multiple playlists of
399
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.
400
417
  - **O4 — Notifications**: should a cancelled-on-disappear item post a chat/PM
401
418
  notice, or refund silently? (Plan: silent refund + WS state update; easy to add
402
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.
@@ -17,6 +17,7 @@ from .catalog.images import CoverArtResolver
17
17
  from .queue.shadow import QueueShadow
18
18
  from .queue.poller import StatePoller
19
19
  from .queue.presence import PresenceRefundMonitor
20
+ from .promos.director import PromoDirector
20
21
  from .ws.manager import WebSocketManager
21
22
  from .playlists.scheduler import PlaylistScheduler
22
23
  from .auth.rate_limit import RateLimiter
@@ -30,6 +31,7 @@ from .routes.admin_schedules import router as admin_schedules_router
30
31
  from .routes.admin_queue import router as admin_queue_router
31
32
  from .routes.admin_jobs import router as admin_jobs_router
32
33
  from .routes.admin_catalog import router as admin_catalog_router
34
+ from .routes.admin_promos import router as admin_promos_router
33
35
  from .routes.pages import router as pages_router
34
36
  from .ws.handler import router as ws_router
35
37
 
@@ -117,6 +119,14 @@ async def lifespan(app: FastAPI):
117
119
  await shadow.load_from_db()
118
120
  app.state.shadow = shadow
119
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
+
120
130
  # State poller
121
131
  poller = StatePoller(
122
132
  api_gate=api_gate,
@@ -124,6 +134,7 @@ async def lifespan(app: FastAPI):
124
134
  ws_manager=ws_manager,
125
135
  db=db,
126
136
  interval=config.state_poll_interval_sec,
137
+ promo_director=promo_director,
127
138
  )
128
139
  await poller.start()
129
140
  app.state.poller = poller
@@ -231,6 +242,7 @@ def create_app(config: Config) -> FastAPI:
231
242
  app.include_router(admin_queue_router)
232
243
  app.include_router(admin_jobs_router)
233
244
  app.include_router(admin_catalog_router)
245
+ app.include_router(admin_promos_router)
234
246
  app.include_router(ws_router)
235
247
 
236
248
  # Health check
@@ -290,6 +290,23 @@ MIGRATIONS = [
290
290
  """
291
291
  ALTER TABLE playlist_schedules ADD COLUMN fallback_playlist_id INTEGER REFERENCES saved_playlists(id) ON DELETE SET NULL;
292
292
  """,
293
+ # v10: Tag a saved playlist as a promo pool. A non-NULL promo_type marks the
294
+ # playlist as a reserved pool of promo clips for that type; such playlists
295
+ # are hidden from public browse/search and excluded from pay-to-play (same
296
+ # treatment as is_immutable). NULL = a normal playlist.
297
+ """
298
+ ALTER TABLE saved_playlists ADD COLUMN promo_type TEXT;
299
+ CREATE INDEX IF NOT EXISTS idx_saved_playlists_promo ON saved_playlists(promo_type);
300
+ """,
301
+ # v11: Annotate live promo items inserted into the queue shadow. is_promo
302
+ # marks a system-inserted promo clip; promo_type is its pool; lead_in_for_uid
303
+ # links a Feature-Presentation / Viewer's-Choice lead-in to the content uid
304
+ # it immediately precedes (NULL for general cadence promos).
305
+ """
306
+ ALTER TABLE queue_shadow ADD COLUMN is_promo BOOLEAN NOT NULL DEFAULT 0;
307
+ ALTER TABLE queue_shadow ADD COLUMN promo_type TEXT;
308
+ ALTER TABLE queue_shadow ADD COLUMN lead_in_for_uid INTEGER;
309
+ """,
293
310
  ]
294
311
 
295
312
 
@@ -351,7 +368,7 @@ class Database:
351
368
  WHERE c.friendly_token NOT IN (
352
369
  SELECT spi.media_id FROM saved_playlist_items spi
353
370
  JOIN saved_playlists sp ON spi.playlist_id = sp.id
354
- WHERE sp.is_immutable = 1 AND spi.media_type = 'cm'
371
+ WHERE (sp.is_immutable = 1 OR sp.promo_type IS NOT NULL) AND spi.media_type = 'cm'
355
372
  )
356
373
  """
357
374
  params: list = []
@@ -400,7 +417,7 @@ class Database:
400
417
  WHERE c.friendly_token NOT IN (
401
418
  SELECT spi.media_id FROM saved_playlist_items spi
402
419
  JOIN saved_playlists sp ON spi.playlist_id = sp.id
403
- WHERE sp.is_immutable = 1 AND spi.media_type = 'cm'
420
+ WHERE (sp.is_immutable = 1 OR sp.promo_type IS NOT NULL) AND spi.media_type = 'cm'
404
421
  )
405
422
  """
406
423
  params: list = []
@@ -439,7 +456,7 @@ class Database:
439
456
  AND c.friendly_token NOT IN (
440
457
  SELECT spi.media_id FROM saved_playlist_items spi
441
458
  JOIN saved_playlists sp ON spi.playlist_id = sp.id
442
- WHERE sp.is_immutable = 1 AND spi.media_type = 'cm'
459
+ WHERE (sp.is_immutable = 1 OR sp.promo_type IS NOT NULL) AND spi.media_type = 'cm'
443
460
  )
444
461
  """
445
462
  params: list = [query_text]
@@ -463,7 +480,7 @@ class Database:
463
480
  AND c.friendly_token NOT IN (
464
481
  SELECT spi.media_id FROM saved_playlist_items spi
465
482
  JOIN saved_playlists sp ON spi.playlist_id = sp.id
466
- WHERE sp.is_immutable = 1 AND spi.media_type = 'cm'
483
+ WHERE (sp.is_immutable = 1 OR sp.promo_type IS NOT NULL) AND spi.media_type = 'cm'
467
484
  )
468
485
  """
469
486
  params: list = [query_text]
@@ -481,7 +498,7 @@ class Database:
481
498
  AND friendly_token NOT IN (
482
499
  SELECT spi.media_id FROM saved_playlist_items spi
483
500
  JOIN saved_playlists sp ON spi.playlist_id = sp.id
484
- WHERE sp.is_immutable = 1 AND spi.media_type = 'cm'
501
+ WHERE (sp.is_immutable = 1 OR sp.promo_type IS NOT NULL) AND spi.media_type = 'cm'
485
502
  )
486
503
  """
487
504
  return await self._fetch_one(sql, [friendly_token])
@@ -545,7 +562,7 @@ class Database:
545
562
  sql = """
546
563
  SELECT 1 FROM saved_playlist_items spi
547
564
  JOIN saved_playlists sp ON spi.playlist_id = sp.id
548
- WHERE sp.is_immutable = 1
565
+ WHERE (sp.is_immutable = 1 OR sp.promo_type IS NOT NULL)
549
566
  AND spi.media_type = 'cm'
550
567
  AND spi.media_id = ?
551
568
  LIMIT 1
@@ -803,13 +820,17 @@ class Database:
803
820
  async def upsert_shadow_item(self, item: dict):
804
821
  sql = """
805
822
  INSERT OR REPLACE INTO queue_shadow
806
- (uid, position, title, media_type, media_id, duration_sec, is_pay, paid_by, tier, z_cost, schedule_id, added_at)
823
+ (uid, position, title, media_type, media_id, duration_sec, is_pay, paid_by, tier, z_cost, schedule_id,
824
+ is_promo, promo_type, lead_in_for_uid, added_at)
807
825
  VALUES (:uid, :position, :title, :media_type, :media_id, :duration_sec, :is_pay,
808
- :paid_by, :tier, :z_cost, :schedule_id, :added_at)
826
+ :paid_by, :tier, :z_cost, :schedule_id,
827
+ :is_promo, :promo_type, :lead_in_for_uid, :added_at)
809
828
  """
810
829
  defaults = {"paid_by": None, "tier": None, "z_cost": None, "schedule_id": None,
811
- "friendly_token": None, "added_at": datetime.now(UTC).isoformat()}
830
+ "friendly_token": None, "is_promo": 0, "promo_type": None,
831
+ "lead_in_for_uid": None, "added_at": datetime.now(UTC).isoformat()}
812
832
  row = {**defaults, **item}
833
+ row["is_promo"] = int(bool(row.get("is_promo")))
813
834
  await self._db.execute(sql, row)
814
835
  await self._db.commit()
815
836
 
@@ -886,18 +907,39 @@ class Database:
886
907
  async def get_saved_playlist(self, playlist_id: int) -> dict | None:
887
908
  return await self._fetch_one("SELECT * FROM saved_playlists WHERE id=?", [playlist_id])
888
909
 
889
- async def create_saved_playlist(self, *, name: str, description: str | None, is_immutable: bool, created_by: str) -> int:
910
+ async def create_saved_playlist(self, *, name: str, description: str | None, is_immutable: bool, created_by: str, promo_type: str | None = None) -> int:
890
911
  cursor = await self._db.execute(
891
- "INSERT INTO saved_playlists (name, description, is_immutable, created_by) VALUES (?, ?, ?, ?)",
892
- [name, description, int(is_immutable), created_by],
912
+ "INSERT INTO saved_playlists (name, description, is_immutable, created_by, promo_type) VALUES (?, ?, ?, ?, ?)",
913
+ [name, description, int(is_immutable), created_by, promo_type],
893
914
  )
894
915
  await self._db.commit()
895
916
  return cursor.lastrowid
896
917
 
897
- async def update_saved_playlist(self, playlist_id: int, *, name: str, description: str | None, is_immutable: bool):
918
+ async def update_saved_playlist(self, playlist_id: int, *, name: str, description: str | None, is_immutable: bool, promo_type: str | None = None):
898
919
  await self._execute(
899
- "UPDATE saved_playlists SET name=?, description=?, is_immutable=?, updated_at=datetime('now') WHERE id=?",
900
- [name, description, int(is_immutable), playlist_id],
920
+ "UPDATE saved_playlists SET name=?, description=?, is_immutable=?, promo_type=?, updated_at=datetime('now') WHERE id=?",
921
+ [name, description, int(is_immutable), promo_type, playlist_id],
922
+ )
923
+
924
+ async def get_promo_pools(self) -> list[dict]:
925
+ """All saved playlists that are designated promo pools (promo_type set)."""
926
+ return await self._fetch_all(
927
+ "SELECT * FROM saved_playlists WHERE promo_type IS NOT NULL ORDER BY promo_type, name"
928
+ )
929
+
930
+ async def get_promo_pool_items(self, promo_type: str) -> list[dict]:
931
+ """Union of clips across every playlist tagged with ``promo_type``.
932
+
933
+ Returns the playlist items in a stable order (playlist id, then
934
+ position) so ``sequential`` selection is deterministic.
935
+ """
936
+ return await self._fetch_all(
937
+ "SELECT spi.media_type, spi.media_id, spi.title, spi.duration_sec "
938
+ "FROM saved_playlist_items spi "
939
+ "JOIN saved_playlists sp ON spi.playlist_id = sp.id "
940
+ "WHERE sp.promo_type = ? "
941
+ "ORDER BY sp.id, spi.position",
942
+ [promo_type],
901
943
  )
902
944
 
903
945
  async def delete_saved_playlist(self, playlist_id: int):
@@ -1,5 +1,5 @@
1
1
  from pathlib import Path
2
- from pydantic import BaseModel
2
+ from pydantic import BaseModel, PrivateAttr
3
3
  import json
4
4
 
5
5
 
@@ -31,20 +31,71 @@ class PresenceRefundConfig(BaseModel):
31
31
  left alone.
32
32
 
33
33
  ``on_afk`` relies on the Robot tracking CyTube's ``setAFK`` event (shipped
34
- in Kryten-Robot v1.10.0). It defaults off; enable it once that Robot
35
- version is deployed to production.
34
+ in Kryten-Robot v1.10.0). It defaults on now that v1.10.0 is released; set it
35
+ off if running against an older Robot whose ``meta.afk`` goes stale.
36
+
37
+ ``notify_user`` PMs the owner when a pending paid item is cancelled & refunded
38
+ so the cancellation isn't silent.
36
39
  """
37
40
 
38
41
  enabled: bool = True
39
42
  on_leave: bool = True
40
- on_afk: bool = False # needs Kryten-Robot >= 1.10.0 deployed
43
+ on_afk: bool = True # needs Kryten-Robot >= 1.10.0 deployed
41
44
  grace_seconds: float = 60.0 # wait before acting; re-check after grace
42
45
  check_interval_seconds: float = 15.0 # how often to evaluate owners
46
+ notify_user: bool = True # PM the owner on cancel/refund
47
+
48
+
49
+ class PromoTypeConfig(BaseModel):
50
+ """Per-type promo settings.
51
+
52
+ ``order`` is ``random`` (uniform over the pool) or ``sequential`` (rotate
53
+ through the pool in stored order, resuming where it left off). ``weight`` is
54
+ the relative frequency among the *general* types when a cadence slot fires
55
+ (ignored for the lead-in types).
56
+ """
57
+
58
+ enabled: bool = True
59
+ order: str = "random" # "random" | "sequential"
60
+ weight: int = 1
61
+
62
+
63
+ class GeneralPromoConfig(BaseModel):
64
+ """Cadence for the general (between-content) promos."""
65
+
66
+ every_n_items: int = 4 # insert a general promo every N content items
67
+ every_m_minutes: float = 20.0 # ...or roughly every M minutes, whichever first
68
+ no_repeat: bool = True # don't play the same clip twice in a row
69
+
70
+
71
+ class PromoConfig(BaseModel):
72
+ """Settings for the promo insertion system (see PromoDirector).
73
+
74
+ Promo clips live in saved playlists tagged with a ``promo_type``. General
75
+ promos (types 1-3) are inserted on a cadence between mutable content;
76
+ Feature-Presentation (movies) and Viewer's-Choice (pay items) lead-ins
77
+ (types 4-5) are attached immediately before a qualifying upcoming item.
78
+ """
79
+
80
+ enabled: bool = True
81
+ movie_threshold_seconds: float = 3600.0
82
+ general: GeneralPromoConfig = GeneralPromoConfig()
83
+ types: dict[str, PromoTypeConfig] = {
84
+ "channel_identity": PromoTypeConfig(order="random", weight=3),
85
+ "event": PromoTypeConfig(order="random", weight=2),
86
+ "mod_shoutout": PromoTypeConfig(order="sequential", weight=1),
87
+ "feature_presentation": PromoTypeConfig(order="random"),
88
+ "viewers_choice": PromoTypeConfig(order="random"),
89
+ }
43
90
 
44
91
 
45
92
  class Config(BaseModel):
46
93
  """Application configuration loaded from JSON file."""
47
94
 
95
+ # Path the config was loaded from; set by ``from_file`` so editable settings
96
+ # (e.g. the promo admin panel) can persist back to the same file.
97
+ _source_path: Path | None = PrivateAttr(default=None)
98
+
48
99
  # Server
49
100
  channel: str = "Q_A"
50
101
  host: str = "0.0.0.0"
@@ -71,6 +122,9 @@ class Config(BaseModel):
71
122
  # Presence-based cancel/refund of pending paid items
72
123
  presence_refund: PresenceRefundConfig = PresenceRefundConfig()
73
124
 
125
+ # Promo insertion system
126
+ promos: PromoConfig = PromoConfig()
127
+
74
128
  # Database
75
129
  db_path: str = "/var/lib/kryten-webqueue/webqueue.db"
76
130
 
@@ -95,5 +149,22 @@ class Config(BaseModel):
95
149
 
96
150
  @classmethod
97
151
  def from_file(cls, path: str | Path) -> "Config":
98
- with open(path) as f:
99
- return cls(**json.load(f))
152
+ with open(path, encoding="utf-8") as f:
153
+ cfg = cls(**json.load(f))
154
+ cfg._source_path = Path(path)
155
+ return cfg
156
+
157
+ def save(self) -> None:
158
+ """Persist the current config back to the file it was loaded from.
159
+
160
+ Writes atomically (temp file + replace) so a crash mid-write can't leave
161
+ a truncated config. Raises if the config has no known source path (e.g.
162
+ constructed in-memory by a test).
163
+ """
164
+ if self._source_path is None:
165
+ raise RuntimeError("Config has no source path; cannot persist changes")
166
+ tmp = self._source_path.with_name(self._source_path.name + ".tmp")
167
+ with open(tmp, "w", encoding="utf-8") as f:
168
+ json.dump(self.model_dump(), f, indent=2)
169
+ f.write("\n")
170
+ tmp.replace(self._source_path)
@@ -0,0 +1,12 @@
1
+ """Promo insertion subsystem.
2
+
3
+ Curated promo playlists (saved playlists tagged with a ``promo_type``) are
4
+ inserted between mutable content by the :class:`PromoDirector`.
5
+ """
6
+
7
+ # The five recognised promo types. Types 1-3 are "general" promos inserted on a
8
+ # cadence between content; types 4-5 are "lead-ins" attached immediately before
9
+ # a specific upcoming item (a mutable-playlist movie, or a pay-to-play item).
10
+ GENERAL_PROMO_TYPES = ("channel_identity", "event", "mod_shoutout")
11
+ LEAD_IN_PROMO_TYPES = ("feature_presentation", "viewers_choice")
12
+ PROMO_TYPES = GENERAL_PROMO_TYPES + LEAD_IN_PROMO_TYPES