kryten-webqueue 0.9.2__tar.gz → 0.9.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/CHANGELOG.md +25 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/PKG-INFO +1 -1
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/catalog/db.py +48 -4
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/playlists/fire.py +28 -1
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/playlists/scheduler.py +2 -2
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/queue/ordering.py +20 -2
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/queue/shadow.py +95 -8
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/admin_schedules.py +24 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/pages.py +4 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/queue.py +25 -6
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/static/css/main.css +15 -4
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/admin/playlists.html +18 -1
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/admin/schedules.html +31 -3
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/base.html +1 -1
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/pyproject.toml +1 -1
- kryten_webqueue-0.9.4/tests/test_phase4_live_fixes.py +250 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/.gitignore +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/README.md +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/config.example.json +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/tests/__init__.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/tests/test_phase3_jobs.py +0 -0
|
@@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.9.4] — 2026-06-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **App version in the footer.** Every view now shows the running `kryten-webqueue` version (e.g. `v0.9.4`) in the footer, sourced from the installed package metadata.
|
|
14
|
+
- **Scheduled-event fallback playlist.** Each schedule can name an optional *mutable* fallback playlist that is appended to the live CyTube queue right after the event's items, so the queue is no longer left empty once a scheduled event is exhausted — no manual intervention required. The fallback items aren't part of the "scheduled event", so they stay available for pay-to-play/search and don't affect when the event lock lifts. Configured via a dropdown (mutable playlists only) in the admin Schedule editor.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Playlist rows with a description no longer break row formatting.** The admin table action cell was a `display:flex` `<td>`, which dropped it from the table layout so its bottom border stopped aligning once a name + description grew taller. Action cells are now normal table cells (top-aligned, inline button layout), so buttons and row separators line up regardless of description length.
|
|
19
|
+
|
|
20
|
+
## [0.9.3] — 2026-06-11
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Queue ETAs are computed from the remainder of the *currently-playing* item and wrap around the playlist.** Previously the schedule was built as if the item at list index 0 always played next, so whenever the now-playing item was not at the head, every ETA was wrong. ETAs now start from the time left on the current item and walk the rest of the list in true play order (item after current → end → wrap to the front), matching CyTube's looping playlist.
|
|
25
|
+
- **Paid items keep their purchase order (FIFO).** New paid items were being anchored against a stale `queue_shadow.position` read from the DB (poll reconciliation only re-indexed positions in memory), which could drop every new item directly after the now-playing item and scramble the order. The FIFO anchor is now derived from the in-memory shadow (the authoritative play order), and reconciliation persists positions back to the DB so DB-backed queries no longer drift.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **One-click playlist Reserve/Release.** The admin Playlists list now has a direct Reserve/Release action (and a Mutable/Immutable status) to toggle a saved playlist's immutability without digging into the editor. Immutable playlists' items stay hidden from public browse/search and reserved for scheduled play; releasing returns them to the catalog and pay-to-play.
|
|
30
|
+
- **Scheduled-event pay-to-play lock with auto-expiry + manual unlock.** While an *immutable* scheduled playlist is playing, pay-to-play is locked so the curated event can't be interrupted. The lock now **auto-lifts once the last scheduled item begins playing** (so viewers can queue content for after the event). Admins get an **Unlock now** button (on the active-event banner and during a schedule's pre-fire window) that lifts the current lock while keeping the schedule armed for future firings — no more deleting the schedule to clear a lock.
|
|
31
|
+
|
|
32
|
+
## [0.9.2] — 2026-06-09
|
|
33
|
+
|
|
9
34
|
### Fixed
|
|
10
35
|
|
|
11
36
|
- Added missing `python-slugify>=8.0` core dependency (required by the `fetch` job).
|
|
@@ -271,6 +271,25 @@ MIGRATIONS = [
|
|
|
271
271
|
"""
|
|
272
272
|
UPDATE catalog SET added_at = synced_at WHERE added_at IS NULL;
|
|
273
273
|
""",
|
|
274
|
+
# v7: Per-schedule pre-fire lock override. Lets an admin lift a currently
|
|
275
|
+
# active pre-fire lock without deleting/unarming the (recurring) schedule.
|
|
276
|
+
# Reset to 0 whenever a recurring schedule re-arms its next occurrence.
|
|
277
|
+
"""
|
|
278
|
+
ALTER TABLE playlist_schedules ADD COLUMN lock_disabled INTEGER NOT NULL DEFAULT 0;
|
|
279
|
+
""",
|
|
280
|
+
# v8: Track the firing's last scheduled item + an in-progress lock override
|
|
281
|
+
# so the scheduled-event lock can auto-lift once the last item begins
|
|
282
|
+
# playing, and admins can disable it mid-event.
|
|
283
|
+
"""
|
|
284
|
+
ALTER TABLE active_schedule ADD COLUMN last_item_uid INTEGER;
|
|
285
|
+
ALTER TABLE active_schedule ADD COLUMN lock_disabled INTEGER NOT NULL DEFAULT 0;
|
|
286
|
+
""",
|
|
287
|
+
# v9: Optional fallback (mutable) playlist appended to the live queue after a
|
|
288
|
+
# scheduled event's items, so the queue isn't left empty when the event is
|
|
289
|
+
# exhausted. NULL = no fallback (legacy behaviour).
|
|
290
|
+
"""
|
|
291
|
+
ALTER TABLE playlist_schedules ADD COLUMN fallback_playlist_id INTEGER REFERENCES saved_playlists(id) ON DELETE SET NULL;
|
|
292
|
+
""",
|
|
274
293
|
]
|
|
275
294
|
|
|
276
295
|
|
|
@@ -976,22 +995,46 @@ class Database:
|
|
|
976
995
|
return await self._fetch_one("SELECT * FROM active_schedule WHERE id=1")
|
|
977
996
|
|
|
978
997
|
async def set_active_schedule(self, *, schedule_id: int, playlist_id: int,
|
|
979
|
-
is_immutable: bool, started_at: str, estimated_end_at: str
|
|
998
|
+
is_immutable: bool, started_at: str, estimated_end_at: str,
|
|
999
|
+
last_item_uid: int | None = None):
|
|
980
1000
|
await self._execute(
|
|
981
|
-
"INSERT OR REPLACE INTO active_schedule
|
|
982
|
-
"
|
|
983
|
-
|
|
1001
|
+
"INSERT OR REPLACE INTO active_schedule "
|
|
1002
|
+
"(id, schedule_id, playlist_id, is_immutable, started_at, estimated_end_at, last_item_uid, lock_disabled) "
|
|
1003
|
+
"VALUES (1, ?, ?, ?, ?, ?, ?, 0)",
|
|
1004
|
+
[schedule_id, playlist_id, int(is_immutable), started_at, estimated_end_at, last_item_uid],
|
|
984
1005
|
)
|
|
985
1006
|
|
|
986
1007
|
async def clear_active_schedule(self):
|
|
987
1008
|
await self._execute("DELETE FROM active_schedule WHERE id=1")
|
|
988
1009
|
|
|
1010
|
+
async def disable_active_lock(self):
|
|
1011
|
+
"""Lift the in-progress scheduled-event lock without ending the event.
|
|
1012
|
+
|
|
1013
|
+
Keeps the ``active_schedule`` row (so banners/state still show the event)
|
|
1014
|
+
and leaves the underlying schedule armed for future occurrences.
|
|
1015
|
+
"""
|
|
1016
|
+
await self._execute("UPDATE active_schedule SET lock_disabled=1 WHERE id=1")
|
|
1017
|
+
|
|
1018
|
+
async def is_event_lock_active(self) -> bool:
|
|
1019
|
+
"""True while an immutable scheduled event is locking pay-to-play.
|
|
1020
|
+
|
|
1021
|
+
Auto-lifts (via :meth:`disable_active_lock`, set when the last scheduled
|
|
1022
|
+
item begins playing) and respects an admin's manual unlock.
|
|
1023
|
+
"""
|
|
1024
|
+
row = await self.get_active_schedule()
|
|
1025
|
+
if not row:
|
|
1026
|
+
return False
|
|
1027
|
+
if not row.get("is_immutable"):
|
|
1028
|
+
return False
|
|
1029
|
+
return not row.get("lock_disabled")
|
|
1030
|
+
|
|
989
1031
|
# --- Pre-fire lock check ---
|
|
990
1032
|
|
|
991
1033
|
async def is_pre_fire_lock_active(self) -> bool:
|
|
992
1034
|
row = await self._fetch_one("""
|
|
993
1035
|
SELECT 1 FROM playlist_schedules
|
|
994
1036
|
WHERE is_active = 1
|
|
1037
|
+
AND lock_disabled = 0
|
|
995
1038
|
AND datetime(fire_at, '-' || pre_fire_lock_minutes || ' minutes') <= datetime('now')
|
|
996
1039
|
AND fire_at > datetime('now')
|
|
997
1040
|
LIMIT 1
|
|
@@ -1007,6 +1050,7 @@ class Database:
|
|
|
1007
1050
|
return await self._fetch_one("""
|
|
1008
1051
|
SELECT * FROM playlist_schedules
|
|
1009
1052
|
WHERE is_active = 1
|
|
1053
|
+
AND lock_disabled = 0
|
|
1010
1054
|
AND datetime(fire_at, '-' || pre_fire_lock_minutes || ' minutes') <= datetime('now')
|
|
1011
1055
|
AND fire_at > datetime('now')
|
|
1012
1056
|
ORDER BY fire_at
|
|
@@ -34,17 +34,43 @@ async def fire_schedule(*, schedule_id: int, api_gate, db, shadow, ws_manager):
|
|
|
34
34
|
# Load scheduled playlist items
|
|
35
35
|
items = await db.get_saved_playlist_items(playlist_id)
|
|
36
36
|
total_duration = 0
|
|
37
|
+
last_item_uid = None
|
|
37
38
|
for item in items:
|
|
38
39
|
try:
|
|
39
|
-
await api_gate.playlist_add(
|
|
40
|
+
add_result = await api_gate.playlist_add(
|
|
40
41
|
media_type=item["media_type"],
|
|
41
42
|
media_id=item["media_id"],
|
|
42
43
|
position="end",
|
|
43
44
|
)
|
|
45
|
+
if isinstance(add_result, dict) and add_result.get("uid") is not None:
|
|
46
|
+
last_item_uid = add_result["uid"]
|
|
44
47
|
total_duration += item.get("duration_sec", 0) or 0
|
|
45
48
|
except Exception as e:
|
|
46
49
|
logger.warning(f"Schedule fire: failed to add {item['media_id']}: {e}")
|
|
47
50
|
|
|
51
|
+
# Append the optional fallback (mutable) playlist AFTER the event items so
|
|
52
|
+
# the live queue isn't left empty once the event is exhausted. The
|
|
53
|
+
# fallback items are not part of the "scheduled event", so they do not
|
|
54
|
+
# change last_item_uid (the event lock still lifts when the last EVENT
|
|
55
|
+
# item begins) and they remain available for pay-to-play/search.
|
|
56
|
+
fallback_id = schedule.get("fallback_playlist_id")
|
|
57
|
+
if fallback_id:
|
|
58
|
+
fallback_items = await db.get_saved_playlist_items(fallback_id)
|
|
59
|
+
for item in fallback_items:
|
|
60
|
+
try:
|
|
61
|
+
await api_gate.playlist_add(
|
|
62
|
+
media_type=item["media_type"],
|
|
63
|
+
media_id=item["media_id"],
|
|
64
|
+
position="end",
|
|
65
|
+
)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.warning(f"Schedule fire: failed to add fallback {item['media_id']}: {e}")
|
|
68
|
+
if fallback_items:
|
|
69
|
+
logger.info(
|
|
70
|
+
f"Schedule {schedule_id}: appended {len(fallback_items)} fallback item(s) "
|
|
71
|
+
f"from playlist {fallback_id}"
|
|
72
|
+
)
|
|
73
|
+
|
|
48
74
|
# Update active schedule
|
|
49
75
|
now = datetime.now(UTC)
|
|
50
76
|
await db.set_active_schedule(
|
|
@@ -53,6 +79,7 @@ async def fire_schedule(*, schedule_id: int, api_gate, db, shadow, ws_manager):
|
|
|
53
79
|
is_immutable=playlist.get("is_immutable", False),
|
|
54
80
|
started_at=now.isoformat(),
|
|
55
81
|
estimated_end_at=(now + timedelta(seconds=total_duration)).isoformat(),
|
|
82
|
+
last_item_uid=last_item_uid,
|
|
56
83
|
)
|
|
57
84
|
|
|
58
85
|
# Mark schedule as fired
|
|
@@ -71,7 +71,7 @@ class PlaylistScheduler:
|
|
|
71
71
|
if nxt:
|
|
72
72
|
nxt_utc = nxt.astimezone(UTC)
|
|
73
73
|
await self._db.update_schedule(
|
|
74
|
-
sched["id"], fire_at=nxt_utc.isoformat(), fired_at=None
|
|
74
|
+
sched["id"], fire_at=nxt_utc.isoformat(), fired_at=None, lock_disabled=0
|
|
75
75
|
)
|
|
76
76
|
self._add_job(sched["id"], nxt_utc)
|
|
77
77
|
logger.info(
|
|
@@ -115,7 +115,7 @@ class PlaylistScheduler:
|
|
|
115
115
|
return
|
|
116
116
|
nxt_utc = nxt.astimezone(UTC)
|
|
117
117
|
await self._db.update_schedule(
|
|
118
|
-
schedule_id, fire_at=nxt_utc.isoformat(), fired_at=None
|
|
118
|
+
schedule_id, fire_at=nxt_utc.isoformat(), fired_at=None, lock_disabled=0
|
|
119
119
|
)
|
|
120
120
|
self._add_job(schedule_id, nxt_utc)
|
|
121
121
|
logger.info(f"Recurring schedule {schedule_id} re-armed for {nxt_utc}")
|
|
@@ -98,6 +98,24 @@ def _shadow_index_after_uid(shadow, target_uid: int | None) -> int:
|
|
|
98
98
|
return len(shadow.items)
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
def _last_pay_uid(shadow) -> int | None:
|
|
102
|
+
"""UID of the last paid item in true play order, read from the in-memory shadow.
|
|
103
|
+
|
|
104
|
+
The in-memory shadow is rebuilt in CyTube playlist order on every poll, so
|
|
105
|
+
it is the authoritative source for FIFO positioning. (The persisted
|
|
106
|
+
``queue_shadow.position`` column can lag between polls because reconciliation
|
|
107
|
+
re-indexes positions in memory only, which previously caused new paid items
|
|
108
|
+
to be anchored against a stale uid and land directly after the now-playing
|
|
109
|
+
item instead of at the tail of the pay queue.)
|
|
110
|
+
"""
|
|
111
|
+
last = None
|
|
112
|
+
for it in shadow.items:
|
|
113
|
+
if it.get("is_pay"):
|
|
114
|
+
last = it.get("uid")
|
|
115
|
+
return last
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
101
119
|
async def _move_after(api_gate, *, uid: int, target_uid: int | None) -> None:
|
|
102
120
|
"""Move uid to immediately after target_uid. No-op when target is None."""
|
|
103
121
|
if target_uid is not None:
|
|
@@ -136,7 +154,7 @@ async def insert_pay_queue(
|
|
|
136
154
|
|
|
137
155
|
# Target position: immediately after the LAST item in the persistent
|
|
138
156
|
# pay-queue list, or after the currently-playing item when none exist.
|
|
139
|
-
last_pay_uid =
|
|
157
|
+
last_pay_uid = _last_pay_uid(shadow)
|
|
140
158
|
if last_pay_uid:
|
|
141
159
|
target_uid = last_pay_uid
|
|
142
160
|
else:
|
|
@@ -404,7 +422,7 @@ async def insert_admin_queue(
|
|
|
404
422
|
# after_purchased: immediately after the LAST persistent pay item,
|
|
405
423
|
# or after the currently-playing item when none exist.
|
|
406
424
|
removed = 0
|
|
407
|
-
last_pay_uid =
|
|
425
|
+
last_pay_uid = _last_pay_uid(shadow)
|
|
408
426
|
if last_pay_uid:
|
|
409
427
|
target_uid = last_pay_uid
|
|
410
428
|
else:
|
|
@@ -86,6 +86,10 @@ class QueueShadow:
|
|
|
86
86
|
merged["media_id"] = media_id
|
|
87
87
|
if not merged.get("media_type") or merged.get("media_type") == "unknown":
|
|
88
88
|
merged["media_type"] = media_type
|
|
89
|
+
# Persist the reconciled position so DB-backed queries
|
|
90
|
+
# (e.g. last paid item) don't drift from true play order.
|
|
91
|
+
if local_map[uid].get("position") != pos:
|
|
92
|
+
await self._db.update_shadow_position(uid, pos)
|
|
89
93
|
else:
|
|
90
94
|
# New item from external source
|
|
91
95
|
merged = {
|
|
@@ -108,6 +112,65 @@ class QueueShadow:
|
|
|
108
112
|
|
|
109
113
|
self._items = new_items
|
|
110
114
|
await self._recalculate_estimated_starts()
|
|
115
|
+
await self._maybe_lift_event_lock()
|
|
116
|
+
|
|
117
|
+
async def _maybe_lift_event_lock(self):
|
|
118
|
+
"""Auto-lift a scheduled-event lock once its last item begins playing.
|
|
119
|
+
|
|
120
|
+
When the currently-playing item is the last item that was loaded by the
|
|
121
|
+
scheduled fire, the curated event is effectively over (only the final
|
|
122
|
+
item remains), so pay-to-play should reopen for content that plays after
|
|
123
|
+
it. Idempotent: a no-op once the lock is already lifted.
|
|
124
|
+
"""
|
|
125
|
+
active = await self._db.get_active_schedule()
|
|
126
|
+
if not active or active.get("lock_disabled"):
|
|
127
|
+
return
|
|
128
|
+
last_uid = active.get("last_item_uid")
|
|
129
|
+
if last_uid is None:
|
|
130
|
+
return
|
|
131
|
+
idx = self._now_playing_index()
|
|
132
|
+
if idx is None:
|
|
133
|
+
return
|
|
134
|
+
cur_uid = self._items[idx].get("uid")
|
|
135
|
+
try:
|
|
136
|
+
if cur_uid is not None and int(cur_uid) == int(last_uid):
|
|
137
|
+
await self._db.disable_active_lock()
|
|
138
|
+
logger.info("Scheduled-event lock lifted: last scheduled item now playing")
|
|
139
|
+
except (TypeError, ValueError):
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
def _now_playing_index(self) -> int | None:
|
|
143
|
+
"""Index of the currently-playing item within ``self._items``.
|
|
144
|
+
|
|
145
|
+
Prefers the playlist ``uid``; falls back to matching the now-playing
|
|
146
|
+
media id/type (CyTube's ``changeMedia`` payload carries no uid). Returns
|
|
147
|
+
None when there is no now-playing item or it cannot be located.
|
|
148
|
+
"""
|
|
149
|
+
if not self._now_playing:
|
|
150
|
+
return None
|
|
151
|
+
np = self._now_playing
|
|
152
|
+
uid = np.get("uid")
|
|
153
|
+
if uid is not None:
|
|
154
|
+
try:
|
|
155
|
+
uid = int(uid)
|
|
156
|
+
except (TypeError, ValueError):
|
|
157
|
+
uid = None
|
|
158
|
+
if uid is not None:
|
|
159
|
+
for i, it in enumerate(self._items):
|
|
160
|
+
try:
|
|
161
|
+
if int(it.get("uid")) == uid:
|
|
162
|
+
return i
|
|
163
|
+
except (TypeError, ValueError):
|
|
164
|
+
continue
|
|
165
|
+
np_id = np.get("id") or np.get("media_id")
|
|
166
|
+
np_type = np.get("type") or np.get("media_type")
|
|
167
|
+
if np_id is not None:
|
|
168
|
+
for i, it in enumerate(self._items):
|
|
169
|
+
if it.get("media_id") == np_id and (
|
|
170
|
+
np_type is None or it.get("media_type") == np_type
|
|
171
|
+
):
|
|
172
|
+
return i
|
|
173
|
+
return None
|
|
111
174
|
|
|
112
175
|
async def _recalculate_estimated_starts(self):
|
|
113
176
|
"""Recalculate estimated start times based on position and now-playing.
|
|
@@ -120,22 +183,46 @@ class QueueShadow:
|
|
|
120
183
|
immune to server clock skew / timezone misconfiguration, which is the
|
|
121
184
|
usual cause of ETAs appearing shifted by a whole UTC offset. The browser
|
|
122
185
|
computes the wall-clock time from its own clock (Date.now() + offset).
|
|
186
|
+
|
|
187
|
+
ETAs are computed from the *remainder of the currently-playing item* and
|
|
188
|
+
proceed with the item AFTER it, wrapping around the end of the list back
|
|
189
|
+
to the items before it (CyTube loops the playlist). The current item may
|
|
190
|
+
sit at any position, not just index 0.
|
|
123
191
|
"""
|
|
124
192
|
if not self._items:
|
|
125
193
|
return
|
|
126
194
|
|
|
127
|
-
|
|
128
|
-
|
|
195
|
+
n = len(self._items)
|
|
196
|
+
now = datetime.now(UTC)
|
|
197
|
+
|
|
198
|
+
# Seconds left on the current item before the next one starts.
|
|
199
|
+
remaining = 0.0
|
|
129
200
|
if self._now_playing:
|
|
130
201
|
np_total = _to_seconds(self._now_playing.get("seconds", self._now_playing.get("duration")))
|
|
131
|
-
remaining = np_total - _to_seconds(self._now_playing.get("currentTime"))
|
|
132
|
-
|
|
202
|
+
remaining = max(0.0, np_total - _to_seconds(self._now_playing.get("currentTime")))
|
|
203
|
+
|
|
204
|
+
np_index = self._now_playing_index()
|
|
205
|
+
|
|
206
|
+
if np_index is None:
|
|
207
|
+
# No identifiable now-playing item: assume the head of the list is
|
|
208
|
+
# next, starting after the current item's remaining time.
|
|
209
|
+
offset = remaining
|
|
210
|
+
for item in self._items:
|
|
211
|
+
item["estimated_start_in_sec"] = round(offset)
|
|
212
|
+
item["estimated_start_at"] = (now + timedelta(seconds=offset)).isoformat()
|
|
213
|
+
offset += _to_seconds(item.get("duration_sec"))
|
|
214
|
+
return
|
|
133
215
|
|
|
134
|
-
now
|
|
135
|
-
|
|
216
|
+
# The now-playing item is on screen now (offset 0).
|
|
217
|
+
np_item = self._items[np_index]
|
|
218
|
+
np_item["estimated_start_in_sec"] = 0
|
|
219
|
+
np_item["estimated_start_at"] = now.isoformat()
|
|
220
|
+
|
|
221
|
+
# Walk the rest in true play order, wrapping past the end of the list.
|
|
222
|
+
offset = remaining
|
|
223
|
+
for step in range(1, n):
|
|
224
|
+
item = self._items[(np_index + step) % n]
|
|
136
225
|
item["estimated_start_in_sec"] = round(offset)
|
|
137
|
-
# Absolute timestamp retained for compatibility; relative offset is
|
|
138
|
-
# what the UI renders.
|
|
139
226
|
item["estimated_start_at"] = (now + timedelta(seconds=offset)).isoformat()
|
|
140
227
|
offset += _to_seconds(item.get("duration_sec"))
|
|
141
228
|
|
|
@@ -53,6 +53,7 @@ async def create_schedule(request: Request, user: dict = Depends(require_admin))
|
|
|
53
53
|
is_recurring=body.get("is_recurring", False),
|
|
54
54
|
rrule=body.get("rrule"),
|
|
55
55
|
pre_fire_lock_minutes=body.get("pre_fire_lock_minutes", 15),
|
|
56
|
+
fallback_playlist_id=body.get("fallback_playlist_id"),
|
|
56
57
|
is_active=True,
|
|
57
58
|
created_by=user["username"],
|
|
58
59
|
)
|
|
@@ -127,3 +128,26 @@ async def clear_active(request: Request, user: dict = Depends(require_admin)):
|
|
|
127
128
|
db = request.app.state.db
|
|
128
129
|
await db.clear_active_schedule()
|
|
129
130
|
return {"success": True}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@router.post("/unlock")
|
|
134
|
+
async def unlock(request: Request, user: dict = Depends(require_admin)):
|
|
135
|
+
"""Lift the currently-active pay-to-play lock without deleting the schedule.
|
|
136
|
+
|
|
137
|
+
Targets the in-progress scheduled-event lock first (keeps the event banner
|
|
138
|
+
and any recurring schedule armed); otherwise lifts an active pre-fire lock
|
|
139
|
+
for its current occurrence only (a recurring schedule re-locks on its next
|
|
140
|
+
firing).
|
|
141
|
+
"""
|
|
142
|
+
db = request.app.state.db
|
|
143
|
+
|
|
144
|
+
if await db.is_event_lock_active():
|
|
145
|
+
await db.disable_active_lock()
|
|
146
|
+
return {"success": True, "lifted": "event"}
|
|
147
|
+
|
|
148
|
+
prefire = await db.get_active_pre_fire_lock()
|
|
149
|
+
if prefire:
|
|
150
|
+
await db.update_schedule(prefire["id"], lock_disabled=1)
|
|
151
|
+
return {"success": True, "lifted": "pre_fire"}
|
|
152
|
+
|
|
153
|
+
return {"success": True, "lifted": None}
|
|
@@ -8,6 +8,10 @@ from ..auth.session import get_current_user
|
|
|
8
8
|
|
|
9
9
|
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
|
10
10
|
|
|
11
|
+
# Expose the package version to every template (used in the footer).
|
|
12
|
+
from .. import __version__ as _wq_version
|
|
13
|
+
templates.env.globals["app_version"] = _wq_version
|
|
14
|
+
|
|
11
15
|
router = APIRouter(tags=["pages"])
|
|
12
16
|
|
|
13
17
|
# Sort keys exposed in the browse/search UI. Order defines the dropdown order.
|
|
@@ -26,6 +26,25 @@ async def _pre_fire_lock_detail(db) -> str:
|
|
|
26
26
|
return f'Pay-to-play is closed ahead of "{label}". Try again after the event.'
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
async def _queue_lock_detail(db) -> str:
|
|
30
|
+
"""Pay-to-play lock message, covering both the pre-fire window and an
|
|
31
|
+
in-progress scheduled event."""
|
|
32
|
+
if await db.is_pre_fire_lock_active():
|
|
33
|
+
return await _pre_fire_lock_detail(db)
|
|
34
|
+
active = await db.get_active_schedule()
|
|
35
|
+
label = "a scheduled event"
|
|
36
|
+
if active and active.get("playlist_id"):
|
|
37
|
+
playlist = await db.get_saved_playlist(active["playlist_id"])
|
|
38
|
+
if playlist and playlist.get("name"):
|
|
39
|
+
label = playlist["name"]
|
|
40
|
+
return f'Pay-to-play is closed during "{label}". It reopens when the last scheduled item begins playing.'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _queue_locked(db) -> bool:
|
|
44
|
+
"""True when pay-to-play is closed by either lock type."""
|
|
45
|
+
return await db.is_pre_fire_lock_active() or await db.is_event_lock_active()
|
|
46
|
+
|
|
47
|
+
|
|
29
48
|
@router.get("/state")
|
|
30
49
|
async def get_queue_state(request: Request, user: dict = Depends(get_current_user)):
|
|
31
50
|
"""Get current queue state."""
|
|
@@ -48,9 +67,9 @@ async def add_to_queue(request: Request, user: dict = Depends(get_current_user))
|
|
|
48
67
|
api_gate = request.app.state.api_gate
|
|
49
68
|
shadow = request.app.state.shadow
|
|
50
69
|
|
|
51
|
-
# Check pre-fire
|
|
52
|
-
if await db
|
|
53
|
-
raise HTTPException(423, await
|
|
70
|
+
# Check pay-to-play locks (pre-fire window or in-progress scheduled event)
|
|
71
|
+
if await _queue_locked(db):
|
|
72
|
+
raise HTTPException(423, await _queue_lock_detail(db))
|
|
54
73
|
|
|
55
74
|
# Look up catalog item
|
|
56
75
|
item = await db.get_item(friendly_token)
|
|
@@ -104,9 +123,9 @@ async def play_next(request: Request, user: dict = Depends(get_current_user)):
|
|
|
104
123
|
api_gate = request.app.state.api_gate
|
|
105
124
|
shadow = request.app.state.shadow
|
|
106
125
|
|
|
107
|
-
# Check pre-fire
|
|
108
|
-
if await db
|
|
109
|
-
raise HTTPException(423, await
|
|
126
|
+
# Check pay-to-play locks (pre-fire window or in-progress scheduled event)
|
|
127
|
+
if await _queue_locked(db):
|
|
128
|
+
raise HTTPException(423, await _queue_lock_detail(db))
|
|
110
129
|
|
|
111
130
|
# Look up catalog item
|
|
112
131
|
item = await db.get_item(friendly_token)
|
|
@@ -804,6 +804,7 @@ a.np-chip {
|
|
|
804
804
|
padding: 0.5rem;
|
|
805
805
|
text-align: left;
|
|
806
806
|
border-bottom: 1px solid var(--border);
|
|
807
|
+
vertical-align: top;
|
|
807
808
|
}
|
|
808
809
|
.admin-table th {
|
|
809
810
|
color: var(--text-secondary);
|
|
@@ -934,6 +935,11 @@ a.np-chip {
|
|
|
934
935
|
border-top: 1px solid var(--border);
|
|
935
936
|
margin-top: 4rem;
|
|
936
937
|
}
|
|
938
|
+
.footer-version {
|
|
939
|
+
opacity: 0.7;
|
|
940
|
+
margin-left: 0.35rem;
|
|
941
|
+
font-variant-numeric: tabular-nums;
|
|
942
|
+
}
|
|
937
943
|
|
|
938
944
|
/* Schedule info */
|
|
939
945
|
.schedule-info {
|
|
@@ -1056,10 +1062,15 @@ a.np-chip {
|
|
|
1056
1062
|
.btn-xs:hover { background: var(--bg-hover); }
|
|
1057
1063
|
.btn-xs:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
1058
1064
|
.row-actions {
|
|
1059
|
-
display:
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1065
|
+
/* Kept as a normal table cell: applying display:flex to a <td> drops it
|
|
1066
|
+
from the table layout, so its bottom border stops aligning with the rest
|
|
1067
|
+
of the row once another cell (e.g. a playlist name + description) grows
|
|
1068
|
+
taller. Lay the buttons out inline instead. */
|
|
1069
|
+
white-space: nowrap;
|
|
1070
|
+
text-align: right;
|
|
1071
|
+
}
|
|
1072
|
+
.row-actions > * + * {
|
|
1073
|
+
margin-left: 0.4rem;
|
|
1063
1074
|
}
|
|
1064
1075
|
.muted { color: var(--text-secondary); font-size: 0.85rem; }
|
|
1065
1076
|
|
{kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
@@ -87,9 +87,12 @@ async function loadPlaylists() {
|
|
|
87
87
|
<tr>
|
|
88
88
|
<td><a href="#" onclick="openEditor(${p.id});return false;">${escapeHtml(p.name)}</a>
|
|
89
89
|
${p.description ? `<div class="muted">${escapeHtml(p.description)}</div>` : ''}</td>
|
|
90
|
-
<td>${p.is_immutable ? '<span class="badge badge-warn">Immutable</span>' : '
|
|
90
|
+
<td>${p.is_immutable ? '<span class="badge badge-warn">Immutable</span>' : '<span class="muted">Mutable</span>'}</td>
|
|
91
91
|
<td>${escapeHtml(p.created_by || '')}</td>
|
|
92
92
|
<td class="row-actions">
|
|
93
|
+
<button class="btn btn-sm" onclick="toggleImmutable(${p.id}, ${p.is_immutable ? 1 : 0}, '${escapeHtml(p.name)}')"
|
|
94
|
+
title="${p.is_immutable ? 'Release items back to the public catalog' : 'Reserve items — hide from public catalog/search'}">
|
|
95
|
+
${p.is_immutable ? 'Release' : 'Reserve'}</button>
|
|
93
96
|
<button class="btn btn-sm" onclick="openEditor(${p.id})">Edit</button>
|
|
94
97
|
<button class="btn btn-sm btn-danger" onclick="deletePlaylist(${p.id}, '${escapeHtml(p.name)}')">Delete</button>
|
|
95
98
|
</td>
|
|
@@ -97,6 +100,20 @@ async function loadPlaylists() {
|
|
|
97
100
|
</table>`;
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
async function toggleImmutable(id, currentlyImmutable, name) {
|
|
104
|
+
const makeImmutable = !currentlyImmutable;
|
|
105
|
+
const verb = makeImmutable ? 'Reserve' : 'Release';
|
|
106
|
+
if (!confirm(`${verb} "${name}"?\n\n${makeImmutable
|
|
107
|
+
? 'Its items will be hidden from the public catalog and search and reserved for scheduled play.'
|
|
108
|
+
: 'Its items will return to the public catalog and become available for pay-to-play.'}`)) return;
|
|
109
|
+
const resp = await fetch(`/admin/playlists/${id}`, {
|
|
110
|
+
method: 'PUT', headers: {'Content-Type': 'application/json'},
|
|
111
|
+
body: JSON.stringify({is_immutable: makeImmutable})
|
|
112
|
+
});
|
|
113
|
+
showToast(resp.ok ? `${verb}d` : `${verb} failed`, resp.ok ? 'success' : 'error');
|
|
114
|
+
loadPlaylists();
|
|
115
|
+
}
|
|
116
|
+
|
|
100
117
|
function showCreateModal() {
|
|
101
118
|
showModal(`
|
|
102
119
|
<h3>New Playlist</h3>
|
{kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
{% block scripts %}
|
|
23
23
|
<script>
|
|
24
24
|
let playlistMap = {};
|
|
25
|
+
let playlistRows = [];
|
|
25
26
|
|
|
26
27
|
function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str == null ? '' : str; return d.innerHTML; }
|
|
27
28
|
|
|
@@ -30,6 +31,7 @@ async function loadPlaylistsForSelect() {
|
|
|
30
31
|
if (!resp.ok) return [];
|
|
31
32
|
const rows = await resp.json();
|
|
32
33
|
playlistMap = {};
|
|
34
|
+
playlistRows = rows;
|
|
33
35
|
rows.forEach(p => playlistMap[p.id] = p.name);
|
|
34
36
|
return rows;
|
|
35
37
|
}
|
|
@@ -42,11 +44,23 @@ async function loadActive() {
|
|
|
42
44
|
if (!a || !a.schedule_id) { banner.classList.add('hidden'); return; }
|
|
43
45
|
const name = playlistMap[a.playlist_id] || `Playlist #${a.playlist_id}`;
|
|
44
46
|
banner.classList.remove('hidden');
|
|
47
|
+
const locked = a.is_immutable && !a.lock_disabled;
|
|
45
48
|
banner.innerHTML = `
|
|
46
49
|
<strong>Active schedule running:</strong> ${escapeHtml(name)}
|
|
47
50
|
${a.is_immutable ? '<span class="badge badge-warn">Immutable</span>' : ''}
|
|
51
|
+
${locked ? '<span class="badge badge-warn">Pay-to-play locked</span>' : '<span class="badge">Unlocked</span>'}
|
|
48
52
|
${a.estimated_end_at ? `<div class="muted">Ends ~${formatLocalDateTime(a.estimated_end_at)}</div>` : ''}
|
|
49
|
-
<div style="margin-top:0.5rem;"
|
|
53
|
+
<div style="margin-top:0.5rem;">
|
|
54
|
+
${locked ? '<button class="btn btn-sm" onclick="unlockNow()">Unlock now</button> ' : ''}
|
|
55
|
+
<button class="btn btn-sm btn-danger" onclick="clearActive()">Clear Active</button>
|
|
56
|
+
</div>`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function unlockNow() {
|
|
60
|
+
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;
|
|
61
|
+
const resp = await fetch('/admin/schedules/unlock', {method: 'POST'});
|
|
62
|
+
showToast(resp.ok ? 'Lock lifted' : 'Failed', resp.ok ? 'success' : 'error');
|
|
63
|
+
loadSchedules();
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
async function clearActive() {
|
|
@@ -69,11 +83,16 @@ async function loadSchedules() {
|
|
|
69
83
|
<tr><th>Label</th><th>Playlist</th><th>Fires</th><th>Lock</th><th>Status</th><th></th></tr>
|
|
70
84
|
${rows.map(s => {
|
|
71
85
|
const fired = s.fired_at ? 'fired' : (new Date(s.fire_at).getTime() < now ? 'past' : 'pending');
|
|
86
|
+
const fireMs = new Date(s.fire_at).getTime();
|
|
87
|
+
const lockMin = s.pre_fire_lock_minutes ?? 15;
|
|
88
|
+
const inPreFire = s.is_active && !isNaN(fireMs)
|
|
89
|
+
&& now >= fireMs - lockMin * 60000 && now < fireMs;
|
|
90
|
+
const preFireLocked = inPreFire && !s.lock_disabled;
|
|
72
91
|
return `<tr>
|
|
73
92
|
<td>${escapeHtml(s.label)}${s.is_recurring ? ' <span class="badge">↻</span>' : ''}</td>
|
|
74
|
-
<td>${escapeHtml(playlistMap[s.playlist_id] || ('#' + s.playlist_id))}</td>
|
|
93
|
+
<td>${escapeHtml(playlistMap[s.playlist_id] || ('#' + s.playlist_id))}${s.fallback_playlist_id ? `<div class="muted">then: ${escapeHtml(playlistMap[s.fallback_playlist_id] || ('#' + s.fallback_playlist_id))}</div>` : ''}</td>
|
|
75
94
|
<td>${formatLocalDateTime(s.fire_at)}</td>
|
|
76
|
-
<td>${s.
|
|
95
|
+
<td>${lockMin}m${preFireLocked ? ' <button class="btn btn-xs" onclick="unlockNow()" title="Lift the active pre-fire lock now">Unlock</button>' : (inPreFire && s.lock_disabled ? ' <span class="muted">(unlocked)</span>' : '')}</td>
|
|
77
96
|
<td><span class="job-status job-status-${fired === 'pending' ? 'running' : (fired === 'fired' ? 'completed' : 'cancelled')}">${s.is_active ? fired : 'disabled'}</span></td>
|
|
78
97
|
<td class="row-actions">
|
|
79
98
|
<button class="btn btn-sm" onclick="fireNow(${s.id}, '${escapeHtml(s.label)}')">Fire Now</button>
|
|
@@ -89,6 +108,11 @@ function scheduleForm(s) {
|
|
|
89
108
|
s = s || {};
|
|
90
109
|
const opts = Object.entries(playlistMap).map(([id, name]) =>
|
|
91
110
|
`<option value="${id}" ${s.playlist_id == id ? 'selected' : ''}>${escapeHtml(name)}</option>`).join('');
|
|
111
|
+
// Fallback options: mutable (non-immutable) playlists only, plus a "none" choice.
|
|
112
|
+
const mutable = playlistRows.filter(p => !p.is_immutable);
|
|
113
|
+
const fallbackOpts = '<option value="">— none (leave queue empty) —</option>' +
|
|
114
|
+
mutable.map(p =>
|
|
115
|
+
`<option value="${p.id}" ${s.fallback_playlist_id == p.id ? 'selected' : ''}>${escapeHtml(p.name)}</option>`).join('');
|
|
92
116
|
// fire_at → datetime-local value (local tz)
|
|
93
117
|
let dtLocal = '';
|
|
94
118
|
if (s.fire_at) {
|
|
@@ -99,6 +123,8 @@ function scheduleForm(s) {
|
|
|
99
123
|
<h3>${s.id ? 'Edit' : 'New'} Schedule</h3>
|
|
100
124
|
<label class="field"><span>Label</span><input type="text" id="s-label" value="${escapeHtml(s.label || '')}"></label>
|
|
101
125
|
<label class="field"><span>Playlist</span><select id="s-playlist">${opts}</select></label>
|
|
126
|
+
<label class="field"><span>Fallback playlist</span><select id="s-fallback">${fallbackOpts}</select></label>
|
|
127
|
+
<p class="muted" style="font-size:0.8rem;margin-top:-0.25rem;">Plays after the scheduled playlist is exhausted, so the queue isn't left empty. Only mutable playlists can be used.</p>
|
|
102
128
|
<label class="field"><span>Fire at</span><input type="datetime-local" id="s-fireat" value="${dtLocal}"></label>
|
|
103
129
|
<label class="field"><span>Pre-fire lock (min)</span><input type="number" id="s-lock" min="0" value="${s.pre_fire_lock_minutes ?? 15}"></label>
|
|
104
130
|
<label class="check"><input type="checkbox" id="s-recur" ${s.is_recurring ? 'checked' : ''}> Recurring</label>
|
|
@@ -134,6 +160,8 @@ function collectSchedule() {
|
|
|
134
160
|
is_recurring: document.getElementById('s-recur').checked,
|
|
135
161
|
rrule: document.getElementById('s-rrule').value.trim() || null,
|
|
136
162
|
};
|
|
163
|
+
const fallbackVal = document.getElementById('s-fallback').value;
|
|
164
|
+
body.fallback_playlist_id = fallbackVal ? parseInt(fallbackVal, 10) : null;
|
|
137
165
|
const active = document.getElementById('s-active');
|
|
138
166
|
if (active) body.is_active = active.checked;
|
|
139
167
|
return body;
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
</main>
|
|
36
36
|
|
|
37
37
|
<footer class="footer">
|
|
38
|
-
<p>© Channel-Z — Powered by <a href="https://github.com/grobertson/kryten-webqueue" target="_blank" rel="noopener">kryten-webqueue</a
|
|
38
|
+
<p>© Channel-Z — Powered by <a href="https://github.com/grobertson/kryten-webqueue" target="_blank" rel="noopener">kryten-webqueue</a>{% if app_version %} <span class="footer-version">v{{ app_version }}</span>{% endif %}</p>
|
|
39
39
|
</footer>
|
|
40
40
|
|
|
41
41
|
<script src="/static/js/main.js"></script>
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Live-testing bug fixes (v0.9.3).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
#1 ETA computed from the remainder of the current item and wrapping around
|
|
5
|
+
the playlist when the now-playing item is not at index 0.
|
|
6
|
+
#2 Paid FIFO anchor derived from the in-memory shadow (true play order).
|
|
7
|
+
#4 Scheduled-event lock: blocks pay-to-play while an immutable event plays,
|
|
8
|
+
auto-lifts when the last scheduled item begins, admin manual unlock, and
|
|
9
|
+
the per-schedule pre-fire lock override.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timedelta, UTC
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from kryten_webqueue.catalog.db import Database
|
|
17
|
+
from kryten_webqueue.queue.shadow import QueueShadow
|
|
18
|
+
from kryten_webqueue.queue.ordering import _last_pay_uid
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
async def db(tmp_path):
|
|
23
|
+
database = Database(str(tmp_path / "test.db"))
|
|
24
|
+
await database.connect()
|
|
25
|
+
await database.run_migrations()
|
|
26
|
+
yield database
|
|
27
|
+
await database.close()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _polled(uid: int, *, seconds: int = 100):
|
|
31
|
+
"""A CyTube-style polled playlist entry."""
|
|
32
|
+
return {
|
|
33
|
+
"uid": uid,
|
|
34
|
+
"queueby": None,
|
|
35
|
+
"media": {"id": f"m{uid}", "type": "cm", "title": f"Item {uid}", "seconds": seconds},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --- #1 ETA wrap-around ---
|
|
40
|
+
|
|
41
|
+
async def test_eta_wraps_from_current_item(db):
|
|
42
|
+
shadow = QueueShadow(db)
|
|
43
|
+
items = [_polled(1), _polled(2), _polled(3), _polled(4)] # 100s each
|
|
44
|
+
# The current item is uid=2 (index 1), 40s elapsed -> 60s remaining.
|
|
45
|
+
now_playing = {"uid": 2, "seconds": 100, "currentTime": 40}
|
|
46
|
+
await shadow.apply_poll_result(items, now_playing)
|
|
47
|
+
|
|
48
|
+
eta = {it["uid"]: it["estimated_start_in_sec"] for it in shadow.items}
|
|
49
|
+
# Current item plays now.
|
|
50
|
+
assert eta[2] == 0
|
|
51
|
+
# Next is uid=3 after the 60s remainder, then uid=4, then wrap to uid=1.
|
|
52
|
+
assert eta[3] == 60
|
|
53
|
+
assert eta[4] == 160
|
|
54
|
+
assert eta[1] == 260
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def test_eta_without_now_playing_starts_at_head(db):
|
|
58
|
+
shadow = QueueShadow(db)
|
|
59
|
+
items = [_polled(1), _polled(2)]
|
|
60
|
+
await shadow.apply_poll_result(items, None)
|
|
61
|
+
eta = {it["uid"]: it["estimated_start_in_sec"] for it in shadow.items}
|
|
62
|
+
assert eta[1] == 0
|
|
63
|
+
assert eta[2] == 100
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- #2 paid FIFO anchor from in-memory shadow ---
|
|
67
|
+
|
|
68
|
+
async def test_last_pay_uid_from_shadow_order(db):
|
|
69
|
+
shadow = QueueShadow(db)
|
|
70
|
+
# Build a shadow directly: now-playing free item, then two paid items.
|
|
71
|
+
shadow._items = [
|
|
72
|
+
{"uid": 10, "is_pay": False},
|
|
73
|
+
{"uid": 11, "is_pay": True},
|
|
74
|
+
{"uid": 12, "is_pay": True},
|
|
75
|
+
{"uid": 13, "is_pay": False},
|
|
76
|
+
]
|
|
77
|
+
assert _last_pay_uid(shadow) == 12
|
|
78
|
+
|
|
79
|
+
# No paid items -> None (caller then anchors after now-playing).
|
|
80
|
+
shadow._items = [{"uid": 1, "is_pay": False}]
|
|
81
|
+
assert _last_pay_uid(shadow) is None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def test_poll_persists_positions(db):
|
|
85
|
+
"""Reconciliation must persist positions so DB-backed queries don't drift."""
|
|
86
|
+
shadow = QueueShadow(db)
|
|
87
|
+
await shadow.apply_poll_result([_polled(1), _polled(2), _polled(3)], None)
|
|
88
|
+
# Reorder externally: uid 3 moves to the front.
|
|
89
|
+
await shadow.apply_poll_result([_polled(3), _polled(1), _polled(2)], None)
|
|
90
|
+
rows = await db.get_shadow_items()
|
|
91
|
+
by_uid = {r["uid"]: r["position"] for r in rows}
|
|
92
|
+
assert by_uid == {3: 0, 1: 1, 2: 2}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --- #4 scheduled-event lock ---
|
|
96
|
+
|
|
97
|
+
async def _make_event(db, *, is_immutable=True, last_item_uid=99):
|
|
98
|
+
pid = await db.create_saved_playlist(
|
|
99
|
+
name="Event", description=None, is_immutable=is_immutable, created_by="admin"
|
|
100
|
+
)
|
|
101
|
+
sid = await db.create_schedule(
|
|
102
|
+
playlist_id=pid,
|
|
103
|
+
label="Event",
|
|
104
|
+
fire_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
|
|
105
|
+
is_active=1,
|
|
106
|
+
created_by="admin",
|
|
107
|
+
)
|
|
108
|
+
now = datetime.now(UTC)
|
|
109
|
+
await db.set_active_schedule(
|
|
110
|
+
schedule_id=sid,
|
|
111
|
+
playlist_id=pid,
|
|
112
|
+
is_immutable=is_immutable,
|
|
113
|
+
started_at=now.isoformat(),
|
|
114
|
+
estimated_end_at=(now + timedelta(hours=1)).isoformat(),
|
|
115
|
+
last_item_uid=last_item_uid,
|
|
116
|
+
)
|
|
117
|
+
return sid, pid
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def test_event_lock_active_and_manual_unlock(db):
|
|
121
|
+
await _make_event(db, is_immutable=True)
|
|
122
|
+
assert await db.is_event_lock_active() is True
|
|
123
|
+
|
|
124
|
+
await db.disable_active_lock()
|
|
125
|
+
assert await db.is_event_lock_active() is False
|
|
126
|
+
# The active row is kept (event still "running"), just unlocked.
|
|
127
|
+
assert (await db.get_active_schedule()) is not None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def test_mutable_event_does_not_lock(db):
|
|
131
|
+
await _make_event(db, is_immutable=False)
|
|
132
|
+
assert await db.is_event_lock_active() is False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def test_event_lock_auto_lifts_when_last_item_plays(db):
|
|
136
|
+
_, _ = await _make_event(db, last_item_uid=3)
|
|
137
|
+
assert await db.is_event_lock_active() is True
|
|
138
|
+
|
|
139
|
+
shadow = QueueShadow(db)
|
|
140
|
+
items = [_polled(1), _polled(2), _polled(3)]
|
|
141
|
+
# The last scheduled item (uid=3) begins playing.
|
|
142
|
+
await shadow.apply_poll_result(items, {"uid": 3, "seconds": 100, "currentTime": 0})
|
|
143
|
+
assert await db.is_event_lock_active() is False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def test_event_lock_stays_until_last_item(db):
|
|
147
|
+
await _make_event(db, last_item_uid=3)
|
|
148
|
+
shadow = QueueShadow(db)
|
|
149
|
+
items = [_polled(1), _polled(2), _polled(3)]
|
|
150
|
+
# An earlier item is playing -> still locked.
|
|
151
|
+
await shadow.apply_poll_result(items, {"uid": 1, "seconds": 100, "currentTime": 0})
|
|
152
|
+
assert await db.is_event_lock_active() is True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --- #4 pre-fire lock override ---
|
|
156
|
+
|
|
157
|
+
async def test_pre_fire_lock_can_be_disabled(db):
|
|
158
|
+
pid = await db.create_saved_playlist(
|
|
159
|
+
name="P", description=None, is_immutable=True, created_by="admin"
|
|
160
|
+
)
|
|
161
|
+
fire_at = (datetime.now(UTC) + timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%S")
|
|
162
|
+
sid = await db.create_schedule(
|
|
163
|
+
playlist_id=pid, label="Soon", fire_at=fire_at,
|
|
164
|
+
pre_fire_lock_minutes=15, is_active=1, created_by="admin",
|
|
165
|
+
)
|
|
166
|
+
# Within the 15-min pre-fire window (fires in 5 min).
|
|
167
|
+
assert await db.is_pre_fire_lock_active() is True
|
|
168
|
+
|
|
169
|
+
await db.update_schedule(sid, lock_disabled=1)
|
|
170
|
+
assert await db.is_pre_fire_lock_active() is False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --- #2 (v0.9.4) scheduled-event fallback playlist ---
|
|
174
|
+
|
|
175
|
+
class _FakeApiGate:
|
|
176
|
+
"""Minimal api_gate stub recording playlist_add calls."""
|
|
177
|
+
|
|
178
|
+
def __init__(self):
|
|
179
|
+
self.added = []
|
|
180
|
+
self._uid = 0
|
|
181
|
+
self.cleared = False
|
|
182
|
+
|
|
183
|
+
async def playlist_clear(self):
|
|
184
|
+
self.cleared = True
|
|
185
|
+
|
|
186
|
+
async def playlist_add(self, *, media_type, media_id, position="end"):
|
|
187
|
+
self._uid += 1
|
|
188
|
+
self.added.append({"media_type": media_type, "media_id": media_id, "uid": self._uid})
|
|
189
|
+
return {"success": True, "uid": self._uid}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _FakeWs:
|
|
193
|
+
async def broadcast(self, *_a, **_k):
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def test_fire_appends_fallback_after_event(db):
|
|
198
|
+
from kryten_webqueue.playlists.fire import fire_schedule
|
|
199
|
+
|
|
200
|
+
event_pid = await db.create_saved_playlist(
|
|
201
|
+
name="Event", description=None, is_immutable=True, created_by="admin"
|
|
202
|
+
)
|
|
203
|
+
await db.replace_playlist_items(event_pid, [
|
|
204
|
+
{"media_type": "cm", "media_id": "e1", "title": "E1", "duration_sec": 100},
|
|
205
|
+
{"media_type": "cm", "media_id": "e2", "title": "E2", "duration_sec": 100},
|
|
206
|
+
])
|
|
207
|
+
fallback_pid = await db.create_saved_playlist(
|
|
208
|
+
name="Filler", description=None, is_immutable=False, created_by="admin"
|
|
209
|
+
)
|
|
210
|
+
await db.replace_playlist_items(fallback_pid, [
|
|
211
|
+
{"media_type": "cm", "media_id": "f1", "title": "F1", "duration_sec": 100},
|
|
212
|
+
])
|
|
213
|
+
sid = await db.create_schedule(
|
|
214
|
+
playlist_id=event_pid, label="Event",
|
|
215
|
+
fire_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
|
|
216
|
+
is_active=1, created_by="admin", fallback_playlist_id=fallback_pid,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
api = _FakeApiGate()
|
|
220
|
+
shadow = QueueShadow(db)
|
|
221
|
+
await fire_schedule(schedule_id=sid, api_gate=api, db=db, shadow=shadow, ws_manager=_FakeWs())
|
|
222
|
+
|
|
223
|
+
# Event items first, fallback appended after.
|
|
224
|
+
assert [a["media_id"] for a in api.added] == ["e1", "e2", "f1"]
|
|
225
|
+
|
|
226
|
+
# The event lock's last item is the last EVENT item (e2, uid=2), NOT the
|
|
227
|
+
# fallback — so the lock lifts when e2 starts, not when the filler plays.
|
|
228
|
+
active = await db.get_active_schedule()
|
|
229
|
+
assert active["last_item_uid"] == 2
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def test_fire_without_fallback_only_adds_event(db):
|
|
233
|
+
from kryten_webqueue.playlists.fire import fire_schedule
|
|
234
|
+
|
|
235
|
+
event_pid = await db.create_saved_playlist(
|
|
236
|
+
name="Event", description=None, is_immutable=True, created_by="admin"
|
|
237
|
+
)
|
|
238
|
+
await db.replace_playlist_items(event_pid, [
|
|
239
|
+
{"media_type": "cm", "media_id": "e1", "title": "E1", "duration_sec": 100},
|
|
240
|
+
])
|
|
241
|
+
sid = await db.create_schedule(
|
|
242
|
+
playlist_id=event_pid, label="Event",
|
|
243
|
+
fire_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
|
|
244
|
+
is_active=1, created_by="admin",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
api = _FakeApiGate()
|
|
248
|
+
shadow = QueueShadow(db)
|
|
249
|
+
await fire_schedule(schedule_id=sid, api_gate=api, db=db, shadow=shadow, ws_manager=_FakeWs())
|
|
250
|
+
assert [a["media_id"] for a in api.added] == ["e1"]
|
|
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.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/_common.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/enrichmeta.py
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/enrichtv.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/fetchurls.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/ytpipe/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/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
|
{kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/catalog/item_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/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
|