kryten-webqueue 0.10.0__tar.gz → 0.14.1__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.10.0 → kryten_webqueue-0.14.1}/CHANGELOG.md +49 -0
  2. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/config.example.json +20 -2
  4. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +26 -0
  5. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/app.py +12 -0
  6. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/catalog/db.py +57 -15
  7. kryten_webqueue-0.14.1/kryten_webqueue/config.py +171 -0
  8. kryten_webqueue-0.14.1/kryten_webqueue/promos/__init__.py +12 -0
  9. kryten_webqueue-0.14.1/kryten_webqueue/promos/director.py +375 -0
  10. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/queue/ordering.py +21 -0
  11. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/queue/poller.py +8 -1
  12. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/queue/presence.py +31 -0
  13. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/queue/shadow.py +3 -0
  14. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/admin_playlists.py +16 -0
  15. kryten_webqueue-0.14.1/kryten_webqueue/routes/admin_promos.py +64 -0
  16. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/pages.py +8 -0
  17. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/queue.py +2 -0
  18. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/static/css/main.css +25 -0
  19. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/admin/index.html +1 -0
  20. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/admin/playlists.html +3 -1
  21. kryten_webqueue-0.14.1/kryten_webqueue/templates/admin/promos.html +212 -0
  22. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/queue/index.html +11 -1
  23. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/pyproject.toml +1 -1
  24. kryten_webqueue-0.14.1/tests/test_config_persistence.py +54 -0
  25. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/tests/test_presence_refund.py +50 -1
  26. kryten_webqueue-0.14.1/tests/test_promo_director.py +376 -0
  27. kryten_webqueue-0.14.1/tests/test_promo_pool_exclusion.py +84 -0
  28. kryten_webqueue-0.10.0/kryten_webqueue/config.py +0 -99
  29. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/.github/workflows/python-publish.yml +0 -0
  30. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/.github/workflows/release.yml +0 -0
  31. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/.gitignore +0 -0
  32. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/README.md +0 -0
  33. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/deploy/kryten-webqueue.service +0 -0
  34. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/deploy/nginx-queue.conf +0 -0
  35. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
  36. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/docs/IMPL_API_GATE.md +0 -0
  37. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/docs/IMPL_ECONOMY.md +0 -0
  38. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/docs/IMPL_KRYTEN_PY.md +0 -0
  39. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/docs/IMPL_ROBOT.md +0 -0
  40. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/docs/PRE_PLAN_GAPS.md +0 -0
  41. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/docs/PRODUCT_PLAN.md +0 -0
  42. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  43. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/__init__.py +0 -0
  44. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/__main__.py +0 -0
  45. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/api_gate/__init__.py +0 -0
  46. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/api_gate/client.py +0 -0
  47. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/auth/__init__.py +0 -0
  48. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/auth/otp.py +0 -0
  49. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/auth/rate_limit.py +0 -0
  50. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/auth/session.py +0 -0
  51. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/catalog/__init__.py +0 -0
  52. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/catalog/images.py +0 -0
  53. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/catalog/mediacms.py +0 -0
  54. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/catalog/sync.py +0 -0
  55. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/__init__.py +0 -0
  56. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  57. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  58. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  59. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  60. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  61. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  62. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  63. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  64. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/jobs/__init__.py +0 -0
  65. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  66. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/jobs/manager.py +0 -0
  67. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/jobs/tasks.py +0 -0
  68. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/playlists/__init__.py +0 -0
  69. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/playlists/bulk_add.py +0 -0
  70. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/playlists/fire.py +0 -0
  71. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/playlists/importer.py +0 -0
  72. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/playlists/scheduler.py +0 -0
  73. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/queue/__init__.py +0 -0
  74. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/__init__.py +0 -0
  75. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/admin_catalog.py +0 -0
  76. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
  77. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/admin_queue.py +0 -0
  78. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
  79. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/auth.py +0 -0
  80. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/catalog.py +0 -0
  81. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/user.py +0 -0
  82. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/static/js/main.js +0 -0
  83. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  84. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
  85. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/auth/login.html +0 -0
  86. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/base.html +0 -0
  87. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
  88. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  89. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  90. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
  91. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/ws/__init__.py +0 -0
  92. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/ws/handler.py +0 -0
  93. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/kryten_webqueue/ws/manager.py +0 -0
  94. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/tests/__init__.py +0 -0
  95. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/tests/test_fetchurls_sharepoint.py +0 -0
  96. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/tests/test_phase1.py +0 -0
  97. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/tests/test_phase2_jobs.py +0 -0
  98. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/tests/test_phase3_jobs.py +0 -0
  99. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/tests/test_phase4_live_fixes.py +0 -0
  100. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/tests/test_playlist_import.py +0 -0
  101. {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.1}/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.1] — 2026-06-13
10
+
11
+ ### Fixed
12
+
13
+ - **Cancel/refund PM notifications only fire for AFK owners.** A user who *leaves* the channel is no longer connected to CyTube and cannot receive a PM, so the leave path now skips the notification entirely (the refund + WS state update still happen). AFK owners — who are still present — are PM'd as before. `presence_refund.notify_user` now governs the AFK case only.
14
+ - **`no_repeat` promo selection is now deterministic.** When a `no_repeat` random draw matched the previous clip it was retried up to 8 times and could still return a repeat (a flaky guarantee for small pools). It now draws from the pool excluding the last clip, so a consecutive repeat never occurs for pools of 2+.
15
+
16
+ ## [0.14.0] — 2026-06-13
17
+
18
+ ### Added
19
+
20
+ - **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()`.
21
+ - **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.
22
+
23
+ ### Changed
24
+
25
+ - **`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.
26
+
27
+ ### Tested
28
+
29
+ - 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).
30
+ - Config save round-trip + no-source-path guard; presence-cancel PM (sent / suppressed) coverage.
31
+
32
+
33
+ ### Added
34
+
35
+ - **Promo admin UI.** A new **Promos** admin page (`/admin/promos`, linked from the admin panel):
36
+ - **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.
37
+ - **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.
38
+ - **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.
39
+ - Backend: `GET /admin/promos/config` and `GET /admin/promos/pools` (admin-only).
40
+
41
+ ## [0.11.0] — 2026-06-13
42
+
43
+ ### Added
44
+
45
+ - **Promo insertion system (`PromoDirector`).** Curated promo clips are now inserted between mutable content as playback advances, driven by the state poller:
46
+ - **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.
47
+ - **Feature Presentation** lead-in immediately before a mutable-playlist movie (`duration_sec >= movie_threshold_seconds`).
48
+ - **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).
49
+ - 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.
50
+ - **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.
51
+ - 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.
52
+ - DB: migration v10 (`promo_type` on `saved_playlists` + index) and v11 (`is_promo`, `promo_type`, `lead_in_for_uid` on `queue_shadow`).
53
+
54
+ ### Note
55
+
56
+ - 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.
57
+
9
58
  ## [0.10.0] — 2026-06-13
10
59
 
11
60
  ### 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.1
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,37 @@ 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 went AFK"), gated by
423
+ `presence_refund.notify_user` (default on) and best-effort so a failed PM never
424
+ blocks the refund.
425
+
426
+ **AMENDED (v0.14.1):** the PM only fires for **AFK** owners. A user who *left*
427
+ the channel is no longer connected to CyTube and cannot receive a PM, so the
428
+ leave path skips the notice (refund + WS state update still happen).
@@ -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):
@@ -0,0 +1,171 @@
1
+ from pathlib import Path
2
+ from pydantic import BaseModel, PrivateAttr
3
+ import json
4
+
5
+
6
+ class FetchUrlsConfig(BaseModel):
7
+ """Settings for the fetchurls job.
8
+
9
+ Reads the Channel Z workbook from SharePoint (Microsoft Graph) when the
10
+ SharePoint fields are configured, otherwise falls back to a local ``.xlsx``
11
+ at ``workbook_path``. SharePoint auth uses a pre-seeded MSAL token cache
12
+ (see ``python -m kryten_webqueue.jobs.fetchurls_auth``); the service only
13
+ acquires tokens *silently* from that cache and never prompts interactively.
14
+ """
15
+
16
+ workbook_path: str = "" # local .xlsx fallback (used when SharePoint unset)
17
+
18
+ # SharePoint / Microsoft Graph (read workbook + write resolved URLs to col F)
19
+ sharepoint_tenant_id: str = ""
20
+ sharepoint_client_id: str = ""
21
+ sharepoint_sharing_url: str = ""
22
+ token_cache_path: str = "" # MSAL cache file, pre-seeded out-of-band
23
+
24
+
25
+ class PresenceRefundConfig(BaseModel):
26
+ """Settings for presence-based cancel/refund of pending paid items.
27
+
28
+ When a viewer who paid to queue an item leaves the channel or goes AFK,
29
+ cancel and refund their not-yet-played paid items after a grace period.
30
+ The currently-playing item is never cancelled; free/scheduled items are
31
+ left alone.
32
+
33
+ ``on_afk`` relies on the Robot tracking CyTube's ``setAFK`` event (shipped
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. This only applies to **AFK** owners — a
39
+ user who left the channel is no longer connected and cannot receive a PM.
40
+ """
41
+
42
+ enabled: bool = True
43
+ on_leave: bool = True
44
+ on_afk: bool = True # needs Kryten-Robot >= 1.10.0 deployed
45
+ grace_seconds: float = 60.0 # wait before acting; re-check after grace
46
+ check_interval_seconds: float = 15.0 # how often to evaluate owners
47
+ notify_user: bool = True # PM the AFK owner on cancel/refund
48
+
49
+
50
+ class PromoTypeConfig(BaseModel):
51
+ """Per-type promo settings.
52
+
53
+ ``order`` is ``random`` (uniform over the pool) or ``sequential`` (rotate
54
+ through the pool in stored order, resuming where it left off). ``weight`` is
55
+ the relative frequency among the *general* types when a cadence slot fires
56
+ (ignored for the lead-in types).
57
+ """
58
+
59
+ enabled: bool = True
60
+ order: str = "random" # "random" | "sequential"
61
+ weight: int = 1
62
+
63
+
64
+ class GeneralPromoConfig(BaseModel):
65
+ """Cadence for the general (between-content) promos."""
66
+
67
+ every_n_items: int = 4 # insert a general promo every N content items
68
+ every_m_minutes: float = 20.0 # ...or roughly every M minutes, whichever first
69
+ no_repeat: bool = True # don't play the same clip twice in a row
70
+
71
+
72
+ class PromoConfig(BaseModel):
73
+ """Settings for the promo insertion system (see PromoDirector).
74
+
75
+ Promo clips live in saved playlists tagged with a ``promo_type``. General
76
+ promos (types 1-3) are inserted on a cadence between mutable content;
77
+ Feature-Presentation (movies) and Viewer's-Choice (pay items) lead-ins
78
+ (types 4-5) are attached immediately before a qualifying upcoming item.
79
+ """
80
+
81
+ enabled: bool = True
82
+ movie_threshold_seconds: float = 3600.0
83
+ general: GeneralPromoConfig = GeneralPromoConfig()
84
+ types: dict[str, PromoTypeConfig] = {
85
+ "channel_identity": PromoTypeConfig(order="random", weight=3),
86
+ "event": PromoTypeConfig(order="random", weight=2),
87
+ "mod_shoutout": PromoTypeConfig(order="sequential", weight=1),
88
+ "feature_presentation": PromoTypeConfig(order="random"),
89
+ "viewers_choice": PromoTypeConfig(order="random"),
90
+ }
91
+
92
+
93
+ class Config(BaseModel):
94
+ """Application configuration loaded from JSON file."""
95
+
96
+ # Path the config was loaded from; set by ``from_file`` so editable settings
97
+ # (e.g. the promo admin panel) can persist back to the same file.
98
+ _source_path: Path | None = PrivateAttr(default=None)
99
+
100
+ # Server
101
+ channel: str = "Q_A"
102
+ host: str = "0.0.0.0"
103
+ port: int = 2010
104
+ secret_key: str
105
+ session_ttl_hours: int = 24
106
+
107
+ # API Gate
108
+ api_gate_url: str = "http://127.0.0.1:24444"
109
+ api_gate_token: str
110
+
111
+ # MediaCMS
112
+ mediacms_url: str = "https://www.dropsugar.com"
113
+ mediacms_token: str
114
+
115
+ # Cover art APIs
116
+ tmdb_api_key: str = ""
117
+ omdb_api_key: str = ""
118
+
119
+ # Jobs (optional; jobs whose config/deps are absent fail fast at run time)
120
+ fetch_cookies_path: str = "" # optional yt-dlp cookies for gated sources
121
+ fetchurls: FetchUrlsConfig = FetchUrlsConfig()
122
+
123
+ # Presence-based cancel/refund of pending paid items
124
+ presence_refund: PresenceRefundConfig = PresenceRefundConfig()
125
+
126
+ # Promo insertion system
127
+ promos: PromoConfig = PromoConfig()
128
+
129
+ # Database
130
+ db_path: str = "/var/lib/kryten-webqueue/webqueue.db"
131
+
132
+ # Images
133
+ image_dir: str = "/var/lib/kryten-webqueue/images"
134
+ placeholder_dir: str = "/var/lib/kryten-webqueue/images/placeholders"
135
+
136
+ # Scheduling
137
+ catalog_sync_interval_hours: int = 4
138
+ pre_fire_lock_minutes_default: int = 15
139
+ state_poll_interval_sec: float = 3.0
140
+
141
+ # Bulk playlist loading (manual import + scheduled fire). CyTube validates
142
+ # each queued item server-side (fetching custom manifests); adding faster
143
+ # than it can validate triggers a transient queueFail (surfaced by api-gate
144
+ # as HTTP 422). Throttle consecutive adds and retry the transient 422.
145
+ playlist_bulk_add_delay_sec: float = 0.5 # pause between consecutive adds
146
+ playlist_bulk_add_max_retries: int = 2 # retries on transient 422
147
+
148
+ # Monitoring
149
+ prometheus_port: int = 28292
150
+
151
+ @classmethod
152
+ def from_file(cls, path: str | Path) -> "Config":
153
+ with open(path, encoding="utf-8") as f:
154
+ cfg = cls(**json.load(f))
155
+ cfg._source_path = Path(path)
156
+ return cfg
157
+
158
+ def save(self) -> None:
159
+ """Persist the current config back to the file it was loaded from.
160
+
161
+ Writes atomically (temp file + replace) so a crash mid-write can't leave
162
+ a truncated config. Raises if the config has no known source path (e.g.
163
+ constructed in-memory by a test).
164
+ """
165
+ if self._source_path is None:
166
+ raise RuntimeError("Config has no source path; cannot persist changes")
167
+ tmp = self._source_path.with_name(self._source_path.name + ".tmp")
168
+ with open(tmp, "w", encoding="utf-8") as f:
169
+ json.dump(self.model_dump(), f, indent=2)
170
+ f.write("\n")
171
+ 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