kryten-webqueue 0.22.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.
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/CHANGELOG.md +12 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/db.py +31 -4
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_schedules.py +49 -11
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/static/css/main.css +27 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/schedules.html +63 -3
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/pyproject.toml +1 -1
- kryten_webqueue-0.23.0/tests/test_schedule_lock.py +130 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/.gitignore +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/README.md +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/config.example.json +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/tests/test_search_facets.py +0 -0
|
@@ -4,6 +4,18 @@ 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
|
+
## [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
|
+
|
|
7
19
|
## [0.22.0] - 2026-06-19
|
|
8
20
|
|
|
9
21
|
### Added
|
|
@@ -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
|
-
"""
|
|
178
|
+
"""End the active pay-to-play lockout, whatever its source.
|
|
140
179
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
189
|
+
lifted.append("event")
|
|
151
190
|
|
|
152
|
-
|
|
153
|
-
if
|
|
154
|
-
|
|
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":
|
|
195
|
+
return {"success": True, "lifted": lifted, "pre_fire_count": pre_fire_count}
|
|
@@ -1210,6 +1210,33 @@ a.np-chip {
|
|
|
1210
1210
|
border-left: 3px solid var(--warning);
|
|
1211
1211
|
}
|
|
1212
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
|
+
|
|
1213
1240
|
/* Modal */
|
|
1214
1241
|
.modal-overlay {
|
|
1215
1242
|
position: fixed;
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
<h1>Schedules</h1>
|
|
8
8
|
<p><a href="/admin">← 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">🔒</span>
|
|
72
|
+
<div class="lock-banner-text">
|
|
73
|
+
<strong>Queue locked — 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:
|
|
217
|
-
// the tab is visible (the backend
|
|
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')
|
|
276
|
+
if (document.visibilityState === 'visible') {
|
|
277
|
+
loadLockStatus();
|
|
278
|
+
loadActive();
|
|
279
|
+
}
|
|
220
280
|
}, 15000);
|
|
221
281
|
</script>
|
|
222
282
|
{% endblock %}
|
|
@@ -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
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/_common.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/ytpipe/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/ytpipe/downloader.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/index.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/promos.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/catalog/item_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/queue/index.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.22.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/user/dashboard.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|