kryten-webqueue 0.21.0__tar.gz → 0.23.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 (106) hide show
  1. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/CHANGELOG.md +17 -1
  2. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/api_gate/client.py +6 -0
  4. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/db.py +31 -4
  5. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_schedules.py +49 -11
  6. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/user.py +29 -1
  7. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/static/css/main.css +31 -0
  8. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/schedules.html +63 -3
  9. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/user/dashboard.html +75 -1
  10. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/pyproject.toml +1 -1
  11. kryten_webqueue-0.23.0/tests/test_schedule_lock.py +130 -0
  12. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/.github/workflows/python-publish.yml +0 -0
  13. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/.github/workflows/release.yml +0 -0
  14. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/.gitignore +0 -0
  15. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/README.md +0 -0
  16. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/config.example.json +0 -0
  17. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/deploy/kryten-webqueue.service +0 -0
  18. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/deploy/nginx-queue.conf +0 -0
  19. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  20. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/IMPL_API_GATE.md +0 -0
  21. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/IMPL_ECONOMY.md +0 -0
  22. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  23. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/IMPL_ROBOT.md +0 -0
  24. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  25. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/PRE_PLAN_GAPS.md +0 -0
  26. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/PRODUCT_PLAN.md +0 -0
  27. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  28. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/UX_POLISH_PLAN.md +0 -0
  29. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/__init__.py +0 -0
  30. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/__main__.py +0 -0
  31. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  32. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/app.py +0 -0
  33. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/auth/__init__.py +0 -0
  34. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/auth/otp.py +0 -0
  35. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  36. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/auth/session.py +0 -0
  37. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/__init__.py +0 -0
  38. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/images.py +0 -0
  39. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  40. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/sync.py +0 -0
  41. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/config.py +0 -0
  42. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/__init__.py +0 -0
  43. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  44. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  45. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  46. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  47. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  48. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  49. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  50. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  51. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/jobs/__init__.py +0 -0
  52. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  53. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/jobs/manager.py +0 -0
  54. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/jobs/tasks.py +0 -0
  55. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/logging_config.py +0 -0
  56. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/__init__.py +0 -0
  57. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  58. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/fire.py +0 -0
  59. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/importer.py +0 -0
  60. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/ordering.py +0 -0
  61. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  62. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/promos/__init__.py +0 -0
  63. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/promos/director.py +0 -0
  64. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/__init__.py +0 -0
  65. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/ordering.py +0 -0
  66. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/poller.py +0 -0
  67. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/presence.py +0 -0
  68. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/shadow.py +0 -0
  69. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/__init__.py +0 -0
  70. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  71. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  72. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  73. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  74. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  75. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/auth.py +0 -0
  76. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/catalog.py +0 -0
  77. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/pages.py +0 -0
  78. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/queue.py +0 -0
  79. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/static/js/main.js +0 -0
  80. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/index.html +0 -0
  81. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  82. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  83. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  84. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/auth/login.html +0 -0
  85. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/base.html +0 -0
  86. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  87. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  88. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  89. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/queue/index.html +0 -0
  90. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/ws/__init__.py +0 -0
  91. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/ws/handler.py +0 -0
  92. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/ws/manager.py +0 -0
  93. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/__init__.py +0 -0
  94. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_config_persistence.py +0 -0
  95. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_fetchurls_sharepoint.py +0 -0
  96. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_phase1.py +0 -0
  97. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_phase2_jobs.py +0 -0
  98. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_phase3_jobs.py +0 -0
  99. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_phase4_live_fixes.py +0 -0
  100. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_playlist_import.py +0 -0
  101. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_presence_refund.py +0 -0
  102. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_promo_director.py +0 -0
  103. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_promo_pool_exclusion.py +0 -0
  104. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_queue_announce.py +0 -0
  105. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_save_results_to_playlist.py +0 -0
  106. {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_search_facets.py +0 -0
@@ -4,7 +4,23 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  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
- ## [Unreleased]
7
+ ## [0.23.0] — 2026-06-21
8
+
9
+ ### Fixed
10
+
11
+ - **Scheduled-event pre-fire lock lingered until midnight.** The pay-to-play pre-fire lock lives in `playlist_schedules` and was gated on `fire_at > datetime('now')`. `fire_at` is stored as a raw ISO string with a `T` separator (e.g. `2026-06-21T15:00:00+00:00`, or `…Z` from the admin UI) while SQLite's `datetime('now')` is space-separated, so the two were compared as **strings** — `'T'` sorts after `' '`, keeping the condition true from fire time until the calendar date rolled over. A 15-minute pre-fire lock effectively lasted until midnight, the queue stayed locked with no active-schedule row to show for it, and "Clear Active" couldn't lift it. The lock queries now wrap `fire_at` in `datetime(…)` so the comparison is over normalized timestamps and the lock releases exactly at `fire_at`. `get_next_schedule` shared the same flaw (already-fired events showed as "next" until midnight) and is fixed too.
12
+
13
+ ### Added
14
+
15
+ - **Clear admin lockout indicator + one-click "End lockout now".** The admin Schedules page now shows a prominent banner whenever pay-to-play is closed — covering the pre-fire window (which has no active-schedule row and was previously invisible until a queue attempt failed) as well as an in-progress immutable event. A new `GET /admin/schedules/lock-status` reports the authoritative state, and `POST /admin/schedules/unlock` now lifts an in-progress event lock **and** every active pre-fire lock in a single action (it previously lifted only one), so one click reliably reopens the queue. Schedules stay armed — a recurring event re-locks on its next firing.
16
+
17
+ [0.23.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.23.0
18
+
19
+ ## [0.22.0] - 2026-06-19
20
+
21
+ ### Added
22
+
23
+ - **Shoutout on the Vanity Items tab.** The dashboard's Vanity tab gains a third item — **Shoutout** — alongside Greeting and Chat color, each now with a short call-to-action. Sending a shoutout posts the user's message to public chat (`📢 <user>: …`) via the new `POST /user/vanity/shoutout` route, which proxies the api-gate `POST /economy/vanity/shoutout` endpoint. The message is the user's own input (validated server-side: trimmed, non-empty, max 200 chars); the cost/availability come from the account summary; the username is always taken from the authenticated session.
8
24
 
9
25
  ## [0.21.0] — 2026-06-17
10
26
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.21.0
3
+ Version: 0.23.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
@@ -108,6 +108,12 @@ class ApiGateClient:
108
108
  "value": value,
109
109
  })
110
110
 
111
+ async def set_vanity_shoutout(self, username: str, value: str) -> dict:
112
+ return await self.post("/economy/vanity/shoutout", json={
113
+ "username": username,
114
+ "value": value,
115
+ })
116
+
111
117
  async def queue_preview(self, username: str, duration_sec: int, tier: str = "queue") -> dict:
112
118
  return await self.post("/economy/queue-preview", json={
113
119
  "username": username,
@@ -1166,12 +1166,19 @@ class Database:
1166
1166
  # --- Pre-fire lock check ---
1167
1167
 
1168
1168
  async def is_pre_fire_lock_active(self) -> bool:
1169
+ # NOTE: fire_at must be wrapped in datetime() on BOTH sides. fire_at is
1170
+ # stored as a raw ISO string ('2026-06-21T15:00:00+00:00' or '...Z'),
1171
+ # whose 'T' separator sorts lexically AFTER the space-separated string
1172
+ # returned by datetime('now'). A bare `fire_at > datetime('now')` is a
1173
+ # string comparison that stays true from fire time until the calendar
1174
+ # day rolls over, so the lock lingered until midnight instead of
1175
+ # releasing at fire_at.
1169
1176
  row = await self._fetch_one("""
1170
1177
  SELECT 1 FROM playlist_schedules
1171
1178
  WHERE is_active = 1
1172
1179
  AND lock_disabled = 0
1173
1180
  AND datetime(fire_at, '-' || pre_fire_lock_minutes || ' minutes') <= datetime('now')
1174
- AND fire_at > datetime('now')
1181
+ AND datetime(fire_at) > datetime('now')
1175
1182
  LIMIT 1
1176
1183
  """)
1177
1184
  return row is not None
@@ -1187,12 +1194,32 @@ class Database:
1187
1194
  WHERE is_active = 1
1188
1195
  AND lock_disabled = 0
1189
1196
  AND datetime(fire_at, '-' || pre_fire_lock_minutes || ' minutes') <= datetime('now')
1190
- AND fire_at > datetime('now')
1191
- ORDER BY fire_at
1197
+ AND datetime(fire_at) > datetime('now')
1198
+ ORDER BY datetime(fire_at)
1192
1199
  LIMIT 1
1193
1200
  """)
1194
1201
 
1202
+ async def disable_active_pre_fire_locks(self) -> int:
1203
+ """Lift ALL currently-active pre-fire locks in a single operation.
1204
+
1205
+ Sets ``lock_disabled = 1`` for every schedule whose pre-fire window is
1206
+ open right now, so one admin action ends the lockout even when more than
1207
+ one schedule's window overlaps. Recurring schedules reset
1208
+ ``lock_disabled`` to 0 when they re-arm, so future firings still lock.
1209
+ Returns the number of schedules affected.
1210
+ """
1211
+ cursor = await self._db.execute("""
1212
+ UPDATE playlist_schedules
1213
+ SET lock_disabled = 1
1214
+ WHERE is_active = 1
1215
+ AND lock_disabled = 0
1216
+ AND datetime(fire_at, '-' || pre_fire_lock_minutes || ' minutes') <= datetime('now')
1217
+ AND datetime(fire_at) > datetime('now')
1218
+ """)
1219
+ await self._db.commit()
1220
+ return cursor.rowcount
1221
+
1195
1222
  async def get_next_schedule(self) -> dict | None:
1196
1223
  return await self._fetch_one(
1197
- "SELECT * FROM playlist_schedules WHERE is_active=1 AND fire_at > datetime('now') ORDER BY fire_at LIMIT 1"
1224
+ "SELECT * FROM playlist_schedules WHERE is_active=1 AND datetime(fire_at) > datetime('now') ORDER BY datetime(fire_at) LIMIT 1"
1198
1225
  )
@@ -134,24 +134,62 @@ async def clear_active(request: Request, user: dict = Depends(require_admin)):
134
134
  return {"success": True}
135
135
 
136
136
 
137
+ @router.get("/lock-status")
138
+ async def lock_status(request: Request, user: dict = Depends(require_admin)):
139
+ """Authoritative pay-to-play lock state for the admin lock banner.
140
+
141
+ Reports *both* lock types so an admin always sees why the queue is closed
142
+ and can end it — whether a schedule is in its pre-fire window (no active
143
+ schedule row) or an immutable event is mid-play.
144
+ """
145
+ db = request.app.state.db
146
+
147
+ # Pre-fire window lives entirely in playlist_schedules (no active_schedule
148
+ # row), which is why "Clear Active" can't see or clear it.
149
+ if await db.is_pre_fire_lock_active():
150
+ lock = await db.get_active_pre_fire_lock() or {}
151
+ return {
152
+ "locked": True,
153
+ "type": "pre_fire",
154
+ "label": lock.get("label"),
155
+ "fire_at": lock.get("fire_at"),
156
+ }
157
+
158
+ # In-progress immutable scheduled event.
159
+ if await db.is_event_lock_active():
160
+ active = await db.get_active_schedule() or {}
161
+ label = None
162
+ if active.get("playlist_id"):
163
+ playlist = await db.get_saved_playlist(active["playlist_id"])
164
+ if playlist:
165
+ label = playlist.get("name")
166
+ return {
167
+ "locked": True,
168
+ "type": "event",
169
+ "label": label,
170
+ "estimated_end_at": active.get("estimated_end_at"),
171
+ }
172
+
173
+ return {"locked": False, "type": None}
174
+
175
+
137
176
  @router.post("/unlock")
138
177
  async def unlock(request: Request, user: dict = Depends(require_admin)):
139
- """Lift the currently-active pay-to-play lock without deleting the schedule.
178
+ """End the active pay-to-play lockout, whatever its source.
140
179
 
141
- Targets the in-progress scheduled-event lock first (keeps the event banner
142
- and any recurring schedule armed); otherwise lifts an active pre-fire lock
143
- for its current occurrence only (a recurring schedule re-locks on its next
144
- firing).
180
+ Lifts an in-progress event lock *and* every currently-active pre-fire lock
181
+ in a single action, so one click reliably reopens pay-to-play. The schedules
182
+ themselves stay armed: a recurring schedule re-locks on its next firing.
145
183
  """
146
184
  db = request.app.state.db
185
+ lifted: list[str] = []
147
186
 
148
187
  if await db.is_event_lock_active():
149
188
  await db.disable_active_lock()
150
- return {"success": True, "lifted": "event"}
189
+ lifted.append("event")
151
190
 
152
- prefire = await db.get_active_pre_fire_lock()
153
- if prefire:
154
- await db.update_schedule(prefire["id"], lock_disabled=1)
155
- return {"success": True, "lifted": "pre_fire"}
191
+ pre_fire_count = await db.disable_active_pre_fire_locks()
192
+ if pre_fire_count:
193
+ lifted.append("pre_fire")
156
194
 
157
- return {"success": True, "lifted": None}
195
+ return {"success": True, "lifted": lifted, "pre_fire_count": pre_fire_count}
@@ -1,5 +1,5 @@
1
1
  from fastapi import APIRouter, Request, Depends, HTTPException
2
- from pydantic import BaseModel
2
+ from pydantic import BaseModel, field_validator
3
3
 
4
4
  from ..auth.session import get_current_user
5
5
 
@@ -28,6 +28,22 @@ class ColorUpdate(BaseModel):
28
28
  value: str
29
29
 
30
30
 
31
+ class ShoutoutRequest(BaseModel):
32
+ # Mirrors the economy's shoutout limits so a bypassed UI still gets a
33
+ # consistent, early rejection instead of forwarding arbitrary-length text.
34
+ value: str
35
+
36
+ @field_validator("value")
37
+ @classmethod
38
+ def _validate_value(cls, v: str) -> str:
39
+ v = v.strip()
40
+ if not v:
41
+ raise ValueError("Shoutout message is required.")
42
+ if len(v) > 200:
43
+ raise ValueError("Message too long (max 200 characters).")
44
+ return v
45
+
46
+
31
47
  @router.post("/vanity/greeting")
32
48
  async def set_vanity_greeting(
33
49
  body: GreetingUpdate, request: Request, user: dict = Depends(get_current_user)
@@ -52,6 +68,18 @@ async def set_vanity_color(
52
68
  raise HTTPException(status_code=400, detail=_economy_error(exc)) from exc
53
69
 
54
70
 
71
+ @router.post("/vanity/shoutout")
72
+ async def set_vanity_shoutout(
73
+ body: ShoutoutRequest, request: Request, user: dict = Depends(get_current_user)
74
+ ):
75
+ """Purchase a shoutout — the bot posts the message to public chat."""
76
+ api_gate = request.app.state.api_gate
77
+ try:
78
+ return await api_gate.set_vanity_shoutout(user["username"], body.value)
79
+ except Exception as exc: # noqa: BLE001
80
+ raise HTTPException(status_code=400, detail=_economy_error(exc)) from exc
81
+
82
+
55
83
  def _economy_error(exc: Exception) -> str:
56
84
  """Extract a human-readable message from an api-gate HTTP error."""
57
85
  import httpx
@@ -975,6 +975,10 @@ a.np-chip {
975
975
  color: var(--text-secondary);
976
976
  font-style: italic;
977
977
  }
978
+ .vanity-cta {
979
+ font-size: 0.78rem;
980
+ margin-top: 0.2rem;
981
+ }
978
982
  .color-swatch {
979
983
  display: inline-block;
980
984
  width: 1rem;
@@ -1206,6 +1210,33 @@ a.np-chip {
1206
1210
  border-left: 3px solid var(--warning);
1207
1211
  }
1208
1212
 
1213
+ /* Pay-to-play lock banner (admin schedules) — deliberately prominent so a
1214
+ lockout is never a surprise discovered only when a queue attempt fails. */
1215
+ .lock-banner {
1216
+ background: rgba(225, 112, 85, 0.12);
1217
+ border: 1px solid var(--danger);
1218
+ border-left: 4px solid var(--danger);
1219
+ border-radius: var(--radius);
1220
+ padding: 0.9rem 1.1rem;
1221
+ margin-bottom: 1.5rem;
1222
+ }
1223
+ .lock-banner-row {
1224
+ display: flex;
1225
+ align-items: center;
1226
+ gap: 0.9rem;
1227
+ flex-wrap: wrap;
1228
+ }
1229
+ .lock-banner-icon {
1230
+ font-size: 1.4rem;
1231
+ line-height: 1;
1232
+ }
1233
+ .lock-banner-text {
1234
+ flex: 1 1 240px;
1235
+ }
1236
+ .lock-banner-text > strong {
1237
+ color: var(--danger);
1238
+ }
1239
+
1209
1240
  /* Modal */
1210
1241
  .modal-overlay {
1211
1242
  position: fixed;
@@ -7,6 +7,7 @@
7
7
  <h1>Schedules</h1>
8
8
  <p><a href="/admin">&larr; Back to Admin</a></p>
9
9
 
10
+ <div id="lock-banner" class="lock-banner hidden"></div>
10
11
  <div id="active-banner" class="schedule-info hidden" style="margin-bottom:1.5rem;"></div>
11
12
 
12
13
  <div class="admin-section">
@@ -36,6 +37,58 @@ async function loadPlaylistsForSelect() {
36
37
  return rows;
37
38
  }
38
39
 
40
+ async function loadLockStatus() {
41
+ // Authoritative, server-side lock indicator. Unlike the active-schedule
42
+ // banner, this also surfaces a pre-fire lockout (which has no active_schedule
43
+ // row) — the case where the queue is locked with nothing obviously "running".
44
+ const banner = document.getElementById('lock-banner');
45
+ let st;
46
+ try {
47
+ const resp = await fetch('/admin/schedules/lock-status');
48
+ if (!resp.ok) { banner.classList.add('hidden'); return; }
49
+ st = await resp.json();
50
+ } catch { banner.classList.add('hidden'); return; }
51
+
52
+ if (!st || !st.locked) {
53
+ banner.classList.add('hidden');
54
+ banner.innerHTML = '';
55
+ return;
56
+ }
57
+
58
+ const label = st.label ? escapeHtml(st.label) : 'a scheduled event';
59
+ let detail;
60
+ if (st.type === 'pre_fire') {
61
+ const when = st.fire_at ? formatLocalDateTime(st.fire_at) : null;
62
+ detail = `Pre-event lockout for <strong>${label}</strong>${when ? ` — fires ${when}` : ''}. Pay-to-play reopens when the event starts.`;
63
+ } else {
64
+ const when = st.estimated_end_at ? formatLocalDateTime(st.estimated_end_at) : null;
65
+ detail = `Event <strong>${label}</strong> is playing${when ? ` — ends ~${when}` : ''}. Pay-to-play reopens when the last scheduled item begins.`;
66
+ }
67
+
68
+ banner.classList.remove('hidden');
69
+ banner.innerHTML = `
70
+ <div class="lock-banner-row">
71
+ <span class="lock-banner-icon" aria-hidden="true">&#128274;</span>
72
+ <div class="lock-banner-text">
73
+ <strong>Queue locked &mdash; pay-to-play is closed.</strong>
74
+ <div class="muted">${detail}</div>
75
+ </div>
76
+ <button class="btn btn-danger btn-sm" onclick="endLockout()">End lockout now</button>
77
+ </div>`;
78
+ }
79
+
80
+ async function endLockout() {
81
+ if (!confirm('End the pay-to-play lockout now? Users can queue items immediately. Schedules stay armed — a recurring event re-locks on its next firing.')) return;
82
+ let ok = false;
83
+ try {
84
+ const resp = await fetch('/admin/schedules/unlock', {method: 'POST'});
85
+ ok = resp.ok;
86
+ } catch { ok = false; }
87
+ showToast(ok ? 'Lockout ended' : 'Failed to end lockout', ok ? 'success' : 'error');
88
+ await loadLockStatus();
89
+ loadSchedules();
90
+ }
91
+
39
92
  async function loadActive() {
40
93
  const banner = document.getElementById('active-banner');
41
94
  const resp = await fetch('/admin/schedules/active');
@@ -69,6 +122,7 @@ async function unlockNow() {
69
122
  if (!confirm('Lift the pay-to-play lock now? Users will be able to queue items again. The schedule stays armed for future firings.')) return;
70
123
  const resp = await fetch('/admin/schedules/unlock', {method: 'POST'});
71
124
  showToast(resp.ok ? 'Lock lifted' : 'Failed', resp.ok ? 'success' : 'error');
125
+ await loadLockStatus();
72
126
  loadSchedules();
73
127
  }
74
128
 
@@ -76,11 +130,13 @@ async function clearActive() {
76
130
  if (!confirm('Clear the active schedule and return to free mode?')) return;
77
131
  const resp = await fetch('/admin/schedules/clear-active', {method: 'POST'});
78
132
  showToast(resp.ok ? 'Cleared' : 'Failed', resp.ok ? 'success' : 'error');
133
+ await loadLockStatus();
79
134
  loadActive();
80
135
  }
81
136
 
82
137
  async function loadSchedules() {
83
138
  await loadPlaylistsForSelect();
139
+ await loadLockStatus();
84
140
  await loadActive();
85
141
  const resp = await fetch('/admin/schedules/');
86
142
  const el = document.getElementById('schedules-list');
@@ -213,10 +269,14 @@ function closeModal() { const m = document.getElementById('admin-modal'); if (m)
213
269
 
214
270
  loadSchedules();
215
271
 
216
- // Keep the active-event banner fresh without a reload: re-check every 15s while
217
- // the tab is visible (the backend clears the row once the event plays out).
272
+ // Keep the lock indicator and active-event banner fresh without a reload:
273
+ // re-check every 15s while the tab is visible (the backend lifts the lock at
274
+ // fire time and clears the active row once the event plays out).
218
275
  setInterval(() => {
219
- if (document.visibilityState === 'visible') loadActive();
276
+ if (document.visibilityState === 'visible') {
277
+ loadLockStatus();
278
+ loadActive();
279
+ }
220
280
  }, 15000);
221
281
  </script>
222
282
  {% endblock %}
@@ -48,13 +48,14 @@
48
48
  </div>
49
49
 
50
50
  <div class="tab-panel" id="tab-vanity" role="tabpanel" hidden>
51
- <p class="muted">Personalize how the bot greets you and your chat color. Each update is a one-time purchase.</p>
51
+ <p class="muted">Personalize how the bot greets you, your chat color, and shout yourself out. Each update is a one-time purchase.</p>
52
52
  <div class="vanity-item">
53
53
  <div class="vanity-row">
54
54
  <span class="vanity-label">Greeting</span>
55
55
  <button class="btn btn-sm" id="edit-greeting" disabled>Edit</button>
56
56
  </div>
57
57
  <div class="vanity-value" id="vanity-greeting">—</div>
58
+ <div class="vanity-cta muted" id="cta-greeting">The bot welcomes you by name when you join.</div>
58
59
  </div>
59
60
  <div class="vanity-item">
60
61
  <div class="vanity-row">
@@ -62,6 +63,15 @@
62
63
  <button class="btn btn-sm" id="edit-color" disabled>Edit</button>
63
64
  </div>
64
65
  <div class="vanity-value" id="vanity-color">—</div>
66
+ <div class="vanity-cta muted" id="cta-color">Stand out — pick a custom color for your chat messages.</div>
67
+ </div>
68
+ <div class="vanity-item">
69
+ <div class="vanity-row">
70
+ <span class="vanity-label">Shoutout</span>
71
+ <button class="btn btn-sm" id="buy-shoutout" disabled>Send</button>
72
+ </div>
73
+ <div class="vanity-value" id="vanity-shoutout">—</div>
74
+ <div class="vanity-cta muted" id="cta-shoutout">Have the bot post your message to the whole channel.</div>
65
75
  </div>
66
76
  </div>
67
77
  </div>
@@ -223,6 +233,20 @@ function renderAccount(a) {
223
233
  cBtn.disabled = enabled.custom_color === false;
224
234
  gBtn.title = costs.custom_greeting ? `${fmt(costs.custom_greeting, sym)} per update` : '';
225
235
  cBtn.title = costs.custom_color ? `${fmt(costs.custom_color, sym)} per update` : '';
236
+
237
+ const shoutEl = document.getElementById('vanity-shoutout');
238
+ const shoutCost = costs.shoutout;
239
+ const shoutAvailable = enabled.shoutout !== false && shoutCost != null;
240
+ if (shoutAvailable) {
241
+ shoutEl.textContent = `Post a one-off message to chat for ${fmt(shoutCost, sym)}.`;
242
+ shoutEl.classList.remove('vanity-unset');
243
+ } else {
244
+ shoutEl.textContent = 'Unavailable';
245
+ shoutEl.classList.add('vanity-unset');
246
+ }
247
+ const sBtn = document.getElementById('buy-shoutout');
248
+ sBtn.disabled = !shoutAvailable;
249
+ sBtn.title = shoutAvailable ? `${fmt(shoutCost, sym)} per shoutout` : '';
226
250
  }
227
251
 
228
252
  // ── Vanity edit dialogs ────────────────────────────────────────
@@ -328,6 +352,55 @@ async function saveColor() {
328
352
  }
329
353
  }
330
354
 
355
+ // ── Shoutout dialog ────────────────────────────────────────────
356
+ function buyShoutout() {
357
+ if (!accountState) return;
358
+ const sym = accountState.currency_symbol || 'Z';
359
+ const costs = accountState.vanity_costs || {};
360
+ const enabled = accountState.vanity_enabled || {};
361
+ const cost = costs.shoutout;
362
+ if (enabled.shoutout === false || cost == null) {
363
+ showToast('Shoutouts are currently unavailable', 'error');
364
+ return;
365
+ }
366
+ showModal(`
367
+ <h3>Shoutout</h3>
368
+ <p class="muted">The bot posts your message to public chat as <strong>📢 ${escapeHtml(accountState.username || 'you')}: …</strong>. Cost: ${fmt(cost, sym)} each.</p>
369
+ <label class="field"><span>Message (max 200 characters)</span>
370
+ <textarea id="shoutout-input" maxlength="200" rows="3" placeholder="Say something to the whole channel"></textarea></label>
371
+ <div class="modal-actions">
372
+ <button class="btn" onclick="closeModal()">Cancel</button>
373
+ <button class="btn btn-primary" id="shoutout-save">Send shoutout</button>
374
+ </div>`);
375
+ document.getElementById('shoutout-save').addEventListener('click', saveShoutout);
376
+ }
377
+
378
+ async function saveShoutout() {
379
+ const value = document.getElementById('shoutout-input').value.trim();
380
+ if (!value) { showToast('Message cannot be empty', 'error'); return; }
381
+ const btn = document.getElementById('shoutout-save');
382
+ btn.disabled = true;
383
+ let resp;
384
+ try {
385
+ resp = await fetch('/user/vanity/shoutout', {
386
+ method: 'POST',
387
+ headers: {'Content-Type': 'application/json'},
388
+ body: JSON.stringify({value}),
389
+ });
390
+ } catch (e) {
391
+ showToast('Network error', 'error'); btn.disabled = false; return;
392
+ }
393
+ const data = await resp.json().catch(() => ({}));
394
+ if (resp.ok) {
395
+ showToast('Shoutout sent', 'success');
396
+ closeModal();
397
+ loadAccount();
398
+ } else {
399
+ showToast(data.detail || 'Shoutout failed', 'error');
400
+ btn.disabled = false;
401
+ }
402
+ }
403
+
331
404
  // ── Queue history (MIDDLE column, paginated) ───────────────────
332
405
  const QUEUE_LIMIT = 20;
333
406
  let queueOffset = 0;
@@ -440,6 +513,7 @@ function closeModal() {
440
513
  // ── Init ───────────────────────────────────────────────────────
441
514
  document.getElementById('edit-greeting').addEventListener('click', editGreeting);
442
515
  document.getElementById('edit-color').addEventListener('click', editColor);
516
+ document.getElementById('buy-shoutout').addEventListener('click', buyShoutout);
443
517
  document.querySelectorAll('.tx-toggle-btn').forEach(b => b.addEventListener('click', () => {
444
518
  document.querySelectorAll('.tx-toggle-btn').forEach(x => x.classList.remove('active'));
445
519
  b.classList.add('active');
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.21.0"
3
+ version = "0.23.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,130 @@
1
+ """Regression tests for the scheduled-event pre-fire pay-to-play lock.
2
+
3
+ These cover a bug where the pre-fire lock lingered far past ``fire_at`` (until
4
+ the calendar day rolled over at midnight). ``fire_at`` is stored as a raw ISO
5
+ string with a ``T`` separator (e.g. ``2026-06-21T15:00:00+00:00`` from the
6
+ scheduler, or ``...Z`` from the admin UI's ``toISOString``). The lock predicate
7
+ compared it against ``datetime('now')`` (space-separated) without normalizing,
8
+ so SQLite did a *string* comparison in which ``'T'`` (84) sorts after ``' '``
9
+ (32). That kept ``fire_at > datetime('now')`` true from fire time until the date
10
+ prefix changed, so a 15-minute lock effectively lasted until midnight.
11
+
12
+ The fix wraps ``fire_at`` in ``datetime(...)`` on both sides so the comparison
13
+ is over normalized timestamps.
14
+ """
15
+
16
+ from datetime import datetime, timedelta, UTC
17
+
18
+ import pytest
19
+
20
+ from kryten_webqueue.catalog.db import Database
21
+
22
+
23
+ @pytest.fixture
24
+ async def db(tmp_path):
25
+ database = Database(str(tmp_path / "test.db"))
26
+ await database.connect()
27
+ await database.run_migrations()
28
+ yield database
29
+ await database.close()
30
+
31
+
32
+ def _iso_offset(dt: datetime) -> str:
33
+ """Scheduler storage format, e.g. '2026-06-21T15:00:00+00:00'."""
34
+ return dt.astimezone(UTC).isoformat()
35
+
36
+
37
+ def _iso_zulu(dt: datetime) -> str:
38
+ """Admin-UI storage format (JS Date.toISOString), e.g. '...T15:00:00.000Z'."""
39
+ return dt.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
40
+
41
+
42
+ async def _make_schedule(db: Database, *, fire_at: str, lock_minutes: int = 15) -> int:
43
+ return await db.create_schedule(
44
+ playlist_id=None,
45
+ label="Test Event",
46
+ fire_at=fire_at,
47
+ pre_fire_lock_minutes=lock_minutes,
48
+ is_active=True,
49
+ created_by="tester",
50
+ )
51
+
52
+
53
+ @pytest.mark.parametrize("fmt", [_iso_offset, _iso_zulu])
54
+ async def test_pre_fire_lock_releases_at_fire_time(db, fmt):
55
+ """A schedule whose fire_at has passed must NOT keep the queue locked.
56
+
57
+ Before the fix this returned True for the rest of the calendar day.
58
+ """
59
+ past = datetime.now(UTC) - timedelta(hours=2)
60
+ await _make_schedule(db, fire_at=fmt(past))
61
+ assert await db.is_pre_fire_lock_active() is False
62
+ assert await db.get_active_pre_fire_lock() is None
63
+
64
+
65
+ async def test_pre_fire_lock_active_inside_window(db):
66
+ """Inside the pre-fire window (future fire_at), the lock is active."""
67
+ soon = datetime.now(UTC) + timedelta(minutes=5)
68
+ await _make_schedule(db, fire_at=_iso_offset(soon), lock_minutes=15)
69
+ assert await db.is_pre_fire_lock_active() is True
70
+ lock = await db.get_active_pre_fire_lock()
71
+ assert lock is not None and lock["label"] == "Test Event"
72
+
73
+
74
+ async def test_pre_fire_lock_inactive_before_window(db):
75
+ """Before the pre-fire window opens, the lock is not active."""
76
+ later = datetime.now(UTC) + timedelta(hours=2)
77
+ await _make_schedule(db, fire_at=_iso_offset(later), lock_minutes=15)
78
+ assert await db.is_pre_fire_lock_active() is False
79
+
80
+
81
+ async def test_get_next_schedule_ignores_past_fire(db):
82
+ """The 'next schedule' must skip events whose fire_at has already passed.
83
+
84
+ Before the fix a same-day past schedule was returned (and could even sort
85
+ ahead of a genuine upcoming one).
86
+ """
87
+ past = datetime.now(UTC) - timedelta(hours=2)
88
+ future = datetime.now(UTC) + timedelta(hours=3)
89
+ await _make_schedule(db, fire_at=_iso_offset(past))
90
+ future_id = await _make_schedule(db, fire_at=_iso_offset(future))
91
+
92
+ nxt = await db.get_next_schedule()
93
+ assert nxt is not None
94
+ assert nxt["id"] == future_id
95
+
96
+
97
+ async def test_disable_active_pre_fire_locks_ends_lockout(db):
98
+ """One call lifts EVERY active pre-fire lock, across both stored formats."""
99
+ soon = datetime.now(UTC) + timedelta(minutes=5)
100
+ later = datetime.now(UTC) + timedelta(minutes=10)
101
+ await _make_schedule(db, fire_at=_iso_offset(soon), lock_minutes=15)
102
+ await _make_schedule(db, fire_at=_iso_zulu(later), lock_minutes=15)
103
+
104
+ assert await db.is_pre_fire_lock_active() is True
105
+ count = await db.disable_active_pre_fire_locks()
106
+ assert count == 2
107
+ assert await db.is_pre_fire_lock_active() is False
108
+
109
+
110
+ async def test_disable_active_pre_fire_locks_noop_when_unlocked(db):
111
+ """No active window → nothing lifted, returns 0 (idempotent end-lockout)."""
112
+ later = datetime.now(UTC) + timedelta(hours=2) # before its pre-fire window
113
+ await _make_schedule(db, fire_at=_iso_offset(later), lock_minutes=15)
114
+
115
+ assert await db.is_pre_fire_lock_active() is False
116
+ assert await db.disable_active_pre_fire_locks() == 0
117
+
118
+
119
+ async def test_disable_active_pre_fire_locks_leaves_future_windows(db):
120
+ """Lifting the current lockout must not pre-emptively unlock a later event
121
+ whose pre-fire window has not opened yet."""
122
+ soon = datetime.now(UTC) + timedelta(minutes=5) # in-window now
123
+ later = datetime.now(UTC) + timedelta(hours=4) # window opens much later
124
+ await _make_schedule(db, fire_at=_iso_offset(soon), lock_minutes=15)
125
+ later_id = await _make_schedule(db, fire_at=_iso_offset(later), lock_minutes=15)
126
+
127
+ assert await db.disable_active_pre_fire_locks() == 1
128
+ later_sched = await db.get_schedule(later_id)
129
+ assert later_sched["lock_disabled"] == 0
130
+