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.
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/CHANGELOG.md +17 -1
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/api_gate/client.py +6 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/db.py +31 -4
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_schedules.py +49 -11
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/user.py +29 -1
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/static/css/main.css +31 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/schedules.html +63 -3
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/user/dashboard.html +75 -1
- {kryten_webqueue-0.21.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.21.0 → kryten_webqueue-0.23.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/.gitignore +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/README.md +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/config.example.json +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/tests/test_save_results_to_playlist.py +0 -0
- {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
|
-
## [
|
|
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
|
|
|
@@ -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
|
-
"""
|
|
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}
|
|
@@ -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;
|
{kryten_webqueue-0.21.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 %}
|
{kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/user/dashboard.html
RENAMED
|
@@ -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
|
|
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');
|
|
@@ -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
|
{kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.21.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.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/integrations/ytpipe/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.21.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
|
{kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/index.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/admin/promos.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.21.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.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/catalog/item_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.21.0 → kryten_webqueue-0.23.0}/kryten_webqueue/templates/queue/index.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
|