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