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.
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/CHANGELOG.md +42 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/config.example.json +20 -2
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +22 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/app.py +12 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/db.py +57 -15
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/config.py +77 -6
- kryten_webqueue-0.14.0/kryten_webqueue/promos/__init__.py +12 -0
- kryten_webqueue-0.14.0/kryten_webqueue/promos/director.py +372 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/ordering.py +21 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/poller.py +8 -1
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/presence.py +26 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/shadow.py +3 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_playlists.py +16 -0
- kryten_webqueue-0.14.0/kryten_webqueue/routes/admin_promos.py +64 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/pages.py +8 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/queue.py +2 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/static/css/main.css +25 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/index.html +1 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/playlists.html +3 -1
- kryten_webqueue-0.14.0/kryten_webqueue/templates/admin/promos.html +212 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/queue/index.html +11 -1
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/pyproject.toml +1 -1
- kryten_webqueue-0.14.0/tests/test_config_persistence.py +54 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/test_presence_refund.py +34 -1
- kryten_webqueue-0.14.0/tests/test_promo_director.py +376 -0
- kryten_webqueue-0.14.0/tests/test_promo_pool_exclusion.py +84 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/.gitignore +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/README.md +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.10.0 → kryten_webqueue-0.14.0}/tests/test_playlist_import.py +0 -0
- {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
|
|
@@ -26,9 +26,27 @@
|
|
|
26
26
|
"presence_refund": {
|
|
27
27
|
"enabled": true,
|
|
28
28
|
"on_leave": true,
|
|
29
|
-
"on_afk":
|
|
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,
|
|
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,
|
|
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, "
|
|
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
|
|
35
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|