kryten-webqueue 0.9.13__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.
Files changed (93) hide show
  1. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/CHANGELOG.md +7 -0
  2. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/config.example.json +8 -0
  4. kryten_webqueue-0.10.0/docs/PLAN_PRESENCE_AND_PROMOS.md +402 -0
  5. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/app.py +10 -0
  6. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/config.py +23 -0
  7. kryten_webqueue-0.10.0/kryten_webqueue/queue/presence.py +177 -0
  8. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/pyproject.toml +1 -1
  9. kryten_webqueue-0.10.0/tests/test_presence_refund.py +235 -0
  10. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/.github/workflows/python-publish.yml +0 -0
  11. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/.github/workflows/release.yml +0 -0
  12. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/.gitignore +0 -0
  13. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/README.md +0 -0
  14. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/deploy/kryten-webqueue.service +0 -0
  15. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/deploy/nginx-queue.conf +0 -0
  16. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  17. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/docs/IMPL_API_GATE.md +0 -0
  18. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/docs/IMPL_ECONOMY.md +0 -0
  19. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  20. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/docs/IMPL_ROBOT.md +0 -0
  21. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/docs/PRE_PLAN_GAPS.md +0 -0
  22. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/docs/PRODUCT_PLAN.md +0 -0
  23. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  24. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/__init__.py +0 -0
  25. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/__main__.py +0 -0
  26. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  27. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/api_gate/client.py +0 -0
  28. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/auth/__init__.py +0 -0
  29. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/auth/otp.py +0 -0
  30. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  31. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/auth/session.py +0 -0
  32. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/catalog/__init__.py +0 -0
  33. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/catalog/db.py +0 -0
  34. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/catalog/images.py +0 -0
  35. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  36. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/catalog/sync.py +0 -0
  37. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/integrations/__init__.py +0 -0
  38. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  39. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  40. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  41. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  42. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  43. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  44. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  45. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  46. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/jobs/__init__.py +0 -0
  47. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  48. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/jobs/manager.py +0 -0
  49. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/jobs/tasks.py +0 -0
  50. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/playlists/__init__.py +0 -0
  51. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  52. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/playlists/fire.py +0 -0
  53. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/playlists/importer.py +0 -0
  54. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  55. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/queue/__init__.py +0 -0
  56. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/queue/ordering.py +0 -0
  57. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/queue/poller.py +0 -0
  58. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/queue/shadow.py +0 -0
  59. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/routes/__init__.py +0 -0
  60. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  61. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  62. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  63. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  64. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  65. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/routes/auth.py +0 -0
  66. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/routes/catalog.py +0 -0
  67. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/routes/pages.py +0 -0
  68. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/routes/queue.py +0 -0
  69. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/routes/user.py +0 -0
  70. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/static/css/main.css +0 -0
  71. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/static/js/main.js +0 -0
  72. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/templates/admin/index.html +0 -0
  73. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  74. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  75. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  76. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/templates/auth/login.html +0 -0
  77. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/templates/base.html +0 -0
  78. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  79. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  80. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  81. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/templates/queue/index.html +0 -0
  82. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  83. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/ws/__init__.py +0 -0
  84. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/ws/handler.py +0 -0
  85. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/kryten_webqueue/ws/manager.py +0 -0
  86. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/tests/__init__.py +0 -0
  87. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/tests/test_fetchurls_sharepoint.py +0 -0
  88. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/tests/test_phase1.py +0 -0
  89. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/tests/test_phase2_jobs.py +0 -0
  90. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/tests/test_phase3_jobs.py +0 -0
  91. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/tests/test_phase4_live_fixes.py +0 -0
  92. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/tests/test_playlist_import.py +0 -0
  93. {kryten_webqueue-0.9.13 → kryten_webqueue-0.10.0}/tests/test_queue_announce.py +0 -0
@@ -6,6 +6,13 @@ 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
+
9
16
  ## [0.9.13] — 2026-06-12
10
17
 
11
18
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.9.13
3
+ Version: 0.10.0
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -23,6 +23,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",
@@ -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
@@ -139,6 +140,14 @@ async def lifespan(app: FastAPI):
139
140
  await scheduler.start()
140
141
  app.state.scheduler = scheduler
141
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
+
142
151
  # Background workers
143
152
  async def _catalog_sync_loop():
144
153
  interval = config.catalog_sync_interval_hours * 3600
@@ -195,6 +204,7 @@ async def lifespan(app: FastAPI):
195
204
  task.cancel()
196
205
  await poller.stop()
197
206
  await scheduler.stop()
207
+ await presence_monitor.stop()
198
208
  await catalog_sync.close()
199
209
  await cover_art.close()
200
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
 
@@ -0,0 +1,177 @@
1
+ import asyncio
2
+ import logging
3
+ from datetime import datetime, UTC
4
+
5
+ from .ordering import refund_item, _now_playing_uid
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class PresenceRefundMonitor:
11
+ """Cancel & refund pending paid items when their owner leaves or goes AFK.
12
+
13
+ Runs its own loop (decoupled from the 3s state poll) on
14
+ ``check_interval_seconds``. Each cycle it looks at the pending paid items in
15
+ the shadow (``is_pay`` and not the currently-playing item), groups them by
16
+ owner, and asks api-gate for each owner's presence. An owner who is gone
17
+ (``online is False``) or AFK (``meta.afk``) starts a grace clock; if they are
18
+ still gone/AFK after ``grace_seconds`` all of their pending paid items are
19
+ refunded and removed. If they return before grace elapses the items are kept.
20
+
21
+ Inconclusive presence lookups (api-gate / robot hiccups) never start the
22
+ grace clock and never cancel — they are ignored for that cycle.
23
+ """
24
+
25
+ def __init__(self, *, api_gate, shadow, db, ws_manager, config):
26
+ self._api_gate = api_gate
27
+ self._shadow = shadow
28
+ self._db = db
29
+ self._ws_manager = ws_manager
30
+ self._config = config
31
+ self._task: asyncio.Task | None = None
32
+ # owner username -> (first_seen_missing, reason)
33
+ self._missing_since: dict[str, tuple[datetime, str]] = {}
34
+
35
+ async def start(self):
36
+ if not self._config.enabled:
37
+ logger.info("PresenceRefundMonitor disabled by config")
38
+ return
39
+ self._task = asyncio.create_task(self._loop())
40
+ logger.info(
41
+ "PresenceRefundMonitor started "
42
+ f"(interval={self._config.check_interval_seconds}s, "
43
+ f"grace={self._config.grace_seconds}s, "
44
+ f"on_leave={self._config.on_leave}, on_afk={self._config.on_afk})"
45
+ )
46
+
47
+ async def stop(self):
48
+ if self._task:
49
+ self._task.cancel()
50
+ try:
51
+ await self._task
52
+ except asyncio.CancelledError:
53
+ pass
54
+ logger.info("PresenceRefundMonitor stopped")
55
+
56
+ async def _loop(self):
57
+ while True:
58
+ try:
59
+ await self.check_once()
60
+ except asyncio.CancelledError:
61
+ raise
62
+ except Exception as e:
63
+ logger.warning(f"Presence check error: {e}")
64
+ await asyncio.sleep(self._config.check_interval_seconds)
65
+
66
+ def _pending_paid_by_owner(self, np_uid: int | None) -> dict[str, list[dict]]:
67
+ """Map owner username -> their pending paid shadow items (excludes now-playing)."""
68
+ owners: dict[str, list[dict]] = {}
69
+ for item in self._shadow.items:
70
+ if not item.get("is_pay"):
71
+ continue
72
+ uid = item.get("uid")
73
+ if np_uid is not None and uid == np_uid:
74
+ continue # never cancel the currently-playing item
75
+ owner = item.get("paid_by")
76
+ if not owner:
77
+ continue
78
+ owners.setdefault(owner, []).append(item)
79
+ return owners
80
+
81
+ @staticmethod
82
+ def _classify(data: dict | None) -> str:
83
+ """Classify an owner's presence: 'gone', 'afk', or 'present'."""
84
+ if not data or data.get("online") is False:
85
+ return "gone"
86
+ meta = data.get("meta") or {}
87
+ if meta.get("afk"):
88
+ return "afk"
89
+ return "present"
90
+
91
+ async def check_once(self) -> int:
92
+ """Evaluate owners once; return the number of items cancelled this cycle."""
93
+ np_uid = await _now_playing_uid(self._api_gate, self._shadow)
94
+ owners = self._pending_paid_by_owner(np_uid)
95
+
96
+ # Drop grace entries for owners with no pending paid items anymore.
97
+ for tracked in list(self._missing_since):
98
+ if tracked not in owners:
99
+ self._missing_since.pop(tracked, None)
100
+
101
+ now = datetime.now(UTC)
102
+ cancelled = 0
103
+
104
+ for owner, items in owners.items():
105
+ try:
106
+ data = await self._api_gate.get_user(owner)
107
+ except Exception:
108
+ # Inconclusive (robot/NATS hiccup): do not start the grace clock
109
+ # and do not clear an existing one — just skip this cycle.
110
+ logger.debug("Presence lookup failed for %s; skipping", owner, exc_info=True)
111
+ continue
112
+
113
+ status = self._classify(data)
114
+ reason = "owner_left" if status == "gone" else "owner_afk"
115
+ actionable = (
116
+ (status == "gone" and self._config.on_leave)
117
+ or (status == "afk" and self._config.on_afk)
118
+ )
119
+
120
+ if not actionable:
121
+ # Present, or the relevant trigger is disabled: keep the items.
122
+ self._missing_since.pop(owner, None)
123
+ continue
124
+
125
+ first = self._missing_since.get(owner)
126
+ if first is None:
127
+ self._missing_since[owner] = (now, reason)
128
+ continue
129
+
130
+ since, _first_reason = first
131
+ if (now - since).total_seconds() < self._config.grace_seconds:
132
+ continue # still within grace
133
+
134
+ # Grace elapsed and still gone/AFK: cancel all pending paid items.
135
+ self._missing_since.pop(owner, None)
136
+ for item in items:
137
+ if await self._cancel_item(item, reason):
138
+ cancelled += 1
139
+
140
+ if cancelled:
141
+ await self._broadcast_state()
142
+ return cancelled
143
+
144
+ async def _cancel_item(self, item: dict, reason: str) -> bool:
145
+ """Refund + remove a single pending paid item. Returns True on success."""
146
+ uid = item.get("uid")
147
+ if uid is None:
148
+ return False
149
+ refunded = await refund_item(
150
+ api_gate=self._api_gate, db=self._db, uid=uid, reason=reason
151
+ )
152
+ if not refunded:
153
+ logger.warning(
154
+ "Presence cancel: no refundable spend for uid=%s (owner=%s); skipping",
155
+ uid, item.get("paid_by"),
156
+ )
157
+ return False
158
+ try:
159
+ await self._api_gate.playlist_delete(uid)
160
+ except Exception:
161
+ logger.warning("Presence cancel: failed to delete uid=%s from CyTube", uid, exc_info=True)
162
+ try:
163
+ await self._shadow.remove(uid)
164
+ except Exception:
165
+ logger.warning("Presence cancel: failed to remove uid=%s from shadow", uid, exc_info=True)
166
+ logger.info(
167
+ "Presence cancel: refunded & removed uid=%s (%s) owner=%s reason=%s",
168
+ uid, item.get("title"), item.get("paid_by"), reason,
169
+ )
170
+ return True
171
+
172
+ async def _broadcast_state(self):
173
+ try:
174
+ state = await self._shadow.get_enriched_state(self._db)
175
+ await self._ws_manager.broadcast({"type": "queue_state", "data": state})
176
+ except Exception:
177
+ logger.debug("Failed to broadcast queue state after presence cancel", exc_info=True)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.9.13"
3
+ version = "0.10.0"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,235 @@
1
+ """PresenceRefundMonitor behaviour (Feature 1).
2
+
3
+ Covers the presence-based cancel/refund of pending paid items: leave/AFK
4
+ triggers, the grace window, now-playing exemption, inconclusive lookups, and
5
+ multi-item cancellation for a single owner.
6
+ """
7
+
8
+ from kryten_webqueue.config import PresenceRefundConfig
9
+ from kryten_webqueue.queue.presence import PresenceRefundMonitor
10
+
11
+
12
+ _RAISE = object()
13
+
14
+
15
+ class _FakeApiGate:
16
+ def __init__(self, users, np=None):
17
+ self._users = dict(users) # username -> response dict (or _RAISE)
18
+ self._np = np
19
+ self.refunds = []
20
+ self.deleted = []
21
+
22
+ async def get_now_playing(self):
23
+ return self._np
24
+
25
+ async def get_user(self, username):
26
+ val = self._users.get(username)
27
+ if val is _RAISE:
28
+ raise RuntimeError("robot/NATS hiccup")
29
+ return val
30
+
31
+ async def playlist_delete(self, uid):
32
+ self.deleted.append(uid)
33
+ return {"success": True}
34
+
35
+ async def queue_refund(self, username, request_id, reason):
36
+ self.refunds.append((username, request_id, reason))
37
+ return {"success": True}
38
+
39
+
40
+ class _FakeShadow:
41
+ def __init__(self, items, now_playing=None):
42
+ self._items = list(items)
43
+ self.now_playing = now_playing
44
+ self.removed = []
45
+
46
+ @property
47
+ def items(self):
48
+ return list(self._items)
49
+
50
+ async def remove(self, uid):
51
+ self._items = [it for it in self._items if it["uid"] != uid]
52
+ self.removed.append(uid)
53
+
54
+ async def get_enriched_state(self, db):
55
+ return {"items": self._items, "now_playing": self.now_playing}
56
+
57
+
58
+ class _FakeDb:
59
+ def __init__(self, spend_map):
60
+ # spend_map: uid -> {"request_id": str, "username": str}
61
+ self._by_uid = dict(spend_map)
62
+ self.refunded = []
63
+
64
+ async def get_request_id_for_uid(self, uid):
65
+ rec = self._by_uid.get(uid)
66
+ return rec["request_id"] if rec else None
67
+
68
+ async def _fetch_one(self, sql, params):
69
+ request_id = params[0]
70
+ for rec in self._by_uid.values():
71
+ if rec["request_id"] == request_id:
72
+ return {"username": rec["username"]}
73
+ return None
74
+
75
+ async def mark_spend_refunded(self, request_id):
76
+ self.refunded.append(request_id)
77
+
78
+
79
+ class _FakeWs:
80
+ def __init__(self):
81
+ self.messages = []
82
+
83
+ async def broadcast(self, message):
84
+ self.messages.append(message)
85
+
86
+
87
+ def _paid(uid, owner, **kw):
88
+ d = {"uid": uid, "title": f"Item {uid}", "is_pay": True, "paid_by": owner}
89
+ d.update(kw)
90
+ return d
91
+
92
+
93
+ def _free(uid, **kw):
94
+ d = {"uid": uid, "title": f"Item {uid}", "is_pay": False, "paid_by": None}
95
+ d.update(kw)
96
+ return d
97
+
98
+
99
+ def _monitor(api, shadow, db, ws, **cfg_kw):
100
+ cfg_kw.setdefault("grace_seconds", 0.0)
101
+ cfg = PresenceRefundConfig(**cfg_kw)
102
+ return PresenceRefundMonitor(
103
+ api_gate=api, shadow=shadow, db=db, ws_manager=ws, config=cfg
104
+ )
105
+
106
+
107
+ async def test_owner_offline_cancels_paid_keeps_free():
108
+ shadow = _FakeShadow([_paid(11, "alice"), _free(12)], now_playing={"uid": 10})
109
+ api = _FakeApiGate({"alice": {"online": False}}, np={"uid": 10})
110
+ db = _FakeDb({11: {"request_id": "r11", "username": "alice"}})
111
+ ws = _FakeWs()
112
+ mon = _monitor(api, shadow, db, ws)
113
+
114
+ assert await mon.check_once() == 0 # first sighting: starts grace
115
+ assert await mon.check_once() == 1 # grace elapsed: acts
116
+
117
+ assert api.refunds == [("alice", "r11", "owner_left")]
118
+ assert 11 in shadow.removed # paid item removed
119
+ assert 12 not in shadow.removed # free item untouched
120
+ assert db.refunded == ["r11"]
121
+ assert ws.messages and ws.messages[-1]["type"] == "queue_state"
122
+
123
+
124
+ async def test_owner_afk_returns_within_grace_keeps_item():
125
+ shadow = _FakeShadow([_paid(11, "bob")], now_playing={"uid": 10})
126
+ api = _FakeApiGate({"bob": {"meta": {"afk": True}}}, np={"uid": 10})
127
+ db = _FakeDb({11: {"request_id": "r11", "username": "bob"}})
128
+ ws = _FakeWs()
129
+ mon = _monitor(api, shadow, db, ws, on_afk=True, grace_seconds=60.0)
130
+
131
+ await mon.check_once() # registers AFK
132
+ assert "bob" in mon._missing_since
133
+
134
+ api._users["bob"] = {"online": True, "meta": {"afk": False}} # returned
135
+ assert await mon.check_once() == 0
136
+
137
+ assert "bob" not in mon._missing_since
138
+ assert api.refunds == []
139
+ assert shadow.removed == []
140
+
141
+
142
+ async def test_now_playing_owner_offline_is_exempt():
143
+ # uid 10 is both the now-playing item and a paid item.
144
+ shadow = _FakeShadow([_paid(10, "carol")], now_playing={"uid": 10})
145
+ api = _FakeApiGate({"carol": {"online": False}}, np={"uid": 10})
146
+ db = _FakeDb({10: {"request_id": "r10", "username": "carol"}})
147
+ ws = _FakeWs()
148
+ mon = _monitor(api, shadow, db, ws)
149
+
150
+ await mon.check_once()
151
+ await mon.check_once()
152
+
153
+ assert api.refunds == []
154
+ assert shadow.removed == []
155
+ assert "carol" not in mon._missing_since
156
+
157
+
158
+ async def test_get_user_error_is_inconclusive():
159
+ shadow = _FakeShadow([_paid(11, "dave")], now_playing={"uid": 10})
160
+ api = _FakeApiGate({"dave": _RAISE}, np={"uid": 10})
161
+ db = _FakeDb({11: {"request_id": "r11", "username": "dave"}})
162
+ ws = _FakeWs()
163
+ mon = _monitor(api, shadow, db, ws)
164
+
165
+ assert await mon.check_once() == 0 # lookup raised: no tracking
166
+ assert "dave" not in mon._missing_since
167
+
168
+ api._users["dave"] = {"online": False} # now a real signal
169
+ await mon.check_once() # registers
170
+ assert await mon.check_once() == 1 # acts
171
+ assert api.refunds == [("dave", "r11", "owner_left")]
172
+
173
+
174
+ async def test_two_items_one_owner_both_cancelled():
175
+ shadow = _FakeShadow([_paid(11, "erin"), _paid(12, "erin")], now_playing={"uid": 10})
176
+ api = _FakeApiGate({"erin": {"online": False}}, np={"uid": 10})
177
+ db = _FakeDb({
178
+ 11: {"request_id": "r11", "username": "erin"},
179
+ 12: {"request_id": "r12", "username": "erin"},
180
+ })
181
+ ws = _FakeWs()
182
+ mon = _monitor(api, shadow, db, ws)
183
+
184
+ await mon.check_once() # register
185
+ assert await mon.check_once() == 2 # both cancelled in one window
186
+
187
+ assert set(shadow.removed) == {11, 12}
188
+ assert ("erin", "r11", "owner_left") in api.refunds
189
+ assert ("erin", "r12", "owner_left") in api.refunds
190
+
191
+
192
+ async def test_afk_ignored_when_on_afk_disabled():
193
+ shadow = _FakeShadow([_paid(11, "frank")], now_playing={"uid": 10})
194
+ api = _FakeApiGate({"frank": {"meta": {"afk": True}}}, np={"uid": 10})
195
+ db = _FakeDb({11: {"request_id": "r11", "username": "frank"}})
196
+ ws = _FakeWs()
197
+ mon = _monitor(api, shadow, db, ws, on_afk=False)
198
+
199
+ await mon.check_once()
200
+ await mon.check_once()
201
+
202
+ assert api.refunds == []
203
+ assert shadow.removed == []
204
+ assert "frank" not in mon._missing_since
205
+
206
+
207
+ async def test_leave_ignored_when_on_leave_disabled():
208
+ shadow = _FakeShadow([_paid(11, "gina")], now_playing={"uid": 10})
209
+ api = _FakeApiGate({"gina": {"online": False}}, np={"uid": 10})
210
+ db = _FakeDb({11: {"request_id": "r11", "username": "gina"}})
211
+ ws = _FakeWs()
212
+ mon = _monitor(api, shadow, db, ws, on_leave=False)
213
+
214
+ await mon.check_once()
215
+ await mon.check_once()
216
+
217
+ assert api.refunds == []
218
+ assert shadow.removed == []
219
+ assert "gina" not in mon._missing_since
220
+
221
+
222
+ async def test_owner_recovers_after_partial_grace_then_leaves_again():
223
+ shadow = _FakeShadow([_paid(11, "hank")], now_playing={"uid": 10})
224
+ api = _FakeApiGate({"hank": {"online": False}}, np={"uid": 10})
225
+ db = _FakeDb({11: {"request_id": "r11", "username": "hank"}})
226
+ ws = _FakeWs()
227
+ mon = _monitor(api, shadow, db, ws, grace_seconds=60.0)
228
+
229
+ await mon.check_once() # gone: grace starts
230
+ assert "hank" in mon._missing_since
231
+
232
+ api._users["hank"] = {"online": True} # returns: grace cleared
233
+ await mon.check_once()
234
+ assert "hank" not in mon._missing_since
235
+ assert shadow.removed == []