kryten-webqueue 0.9.1__tar.gz → 0.9.3__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.1 → kryten_webqueue-0.9.3}/CHANGELOG.md +20 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/PKG-INFO +2 -1
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/catalog/db.py +42 -4
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/playlists/fire.py +5 -1
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/playlists/scheduler.py +2 -2
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/queue/ordering.py +20 -2
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/queue/shadow.py +95 -8
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/admin_schedules.py +23 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/queue.py +25 -6
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/admin/playlists.html +18 -1
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/admin/schedules.html +19 -2
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/pyproject.toml +2 -1
- kryten_webqueue-0.9.3/tests/test_phase4_live_fixes.py +170 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/.gitignore +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/README.md +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/config.example.json +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/tests/__init__.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/tests/test_phase3_jobs.py +0 -0
|
@@ -6,6 +6,26 @@ 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.3] — 2026-06-11
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **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.
|
|
19
|
+
- **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.
|
|
20
|
+
|
|
21
|
+
## [0.9.2] — 2026-06-09
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Added missing `python-slugify>=8.0` core dependency (required by the `fetch` job).
|
|
26
|
+
|
|
27
|
+
## [0.9.1] — 2026-06-09
|
|
28
|
+
|
|
9
29
|
### Fixed
|
|
10
30
|
|
|
11
31
|
- Moved `requests`, `openpyxl`, `pyyaml`, and `yt-dlp` from the optional `[jobs]` extra into core dependencies; the `jobs` extra is removed. This fixes installation under `pipx` and other tools that do not support `package[extra]` syntax.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kryten-webqueue
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.3
|
|
4
4
|
Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
|
|
5
5
|
Author: grobertson
|
|
6
6
|
License-Expression: MIT
|
|
@@ -15,6 +15,7 @@ Requires-Dist: pillow>=10.0
|
|
|
15
15
|
Requires-Dist: pydantic>=2.0
|
|
16
16
|
Requires-Dist: pyjwt>=2.8
|
|
17
17
|
Requires-Dist: python-dateutil>=2.8
|
|
18
|
+
Requires-Dist: python-slugify>=8.0
|
|
18
19
|
Requires-Dist: pyyaml>=6.0
|
|
19
20
|
Requires-Dist: requests>=2.31
|
|
20
21
|
Requires-Dist: uvicorn[standard]>=0.30
|
|
@@ -271,6 +271,19 @@ 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
|
+
""",
|
|
274
287
|
]
|
|
275
288
|
|
|
276
289
|
|
|
@@ -976,22 +989,46 @@ class Database:
|
|
|
976
989
|
return await self._fetch_one("SELECT * FROM active_schedule WHERE id=1")
|
|
977
990
|
|
|
978
991
|
async def set_active_schedule(self, *, schedule_id: int, playlist_id: int,
|
|
979
|
-
is_immutable: bool, started_at: str, estimated_end_at: str
|
|
992
|
+
is_immutable: bool, started_at: str, estimated_end_at: str,
|
|
993
|
+
last_item_uid: int | None = None):
|
|
980
994
|
await self._execute(
|
|
981
|
-
"INSERT OR REPLACE INTO active_schedule
|
|
982
|
-
"
|
|
983
|
-
|
|
995
|
+
"INSERT OR REPLACE INTO active_schedule "
|
|
996
|
+
"(id, schedule_id, playlist_id, is_immutable, started_at, estimated_end_at, last_item_uid, lock_disabled) "
|
|
997
|
+
"VALUES (1, ?, ?, ?, ?, ?, ?, 0)",
|
|
998
|
+
[schedule_id, playlist_id, int(is_immutable), started_at, estimated_end_at, last_item_uid],
|
|
984
999
|
)
|
|
985
1000
|
|
|
986
1001
|
async def clear_active_schedule(self):
|
|
987
1002
|
await self._execute("DELETE FROM active_schedule WHERE id=1")
|
|
988
1003
|
|
|
1004
|
+
async def disable_active_lock(self):
|
|
1005
|
+
"""Lift the in-progress scheduled-event lock without ending the event.
|
|
1006
|
+
|
|
1007
|
+
Keeps the ``active_schedule`` row (so banners/state still show the event)
|
|
1008
|
+
and leaves the underlying schedule armed for future occurrences.
|
|
1009
|
+
"""
|
|
1010
|
+
await self._execute("UPDATE active_schedule SET lock_disabled=1 WHERE id=1")
|
|
1011
|
+
|
|
1012
|
+
async def is_event_lock_active(self) -> bool:
|
|
1013
|
+
"""True while an immutable scheduled event is locking pay-to-play.
|
|
1014
|
+
|
|
1015
|
+
Auto-lifts (via :meth:`disable_active_lock`, set when the last scheduled
|
|
1016
|
+
item begins playing) and respects an admin's manual unlock.
|
|
1017
|
+
"""
|
|
1018
|
+
row = await self.get_active_schedule()
|
|
1019
|
+
if not row:
|
|
1020
|
+
return False
|
|
1021
|
+
if not row.get("is_immutable"):
|
|
1022
|
+
return False
|
|
1023
|
+
return not row.get("lock_disabled")
|
|
1024
|
+
|
|
989
1025
|
# --- Pre-fire lock check ---
|
|
990
1026
|
|
|
991
1027
|
async def is_pre_fire_lock_active(self) -> bool:
|
|
992
1028
|
row = await self._fetch_one("""
|
|
993
1029
|
SELECT 1 FROM playlist_schedules
|
|
994
1030
|
WHERE is_active = 1
|
|
1031
|
+
AND lock_disabled = 0
|
|
995
1032
|
AND datetime(fire_at, '-' || pre_fire_lock_minutes || ' minutes') <= datetime('now')
|
|
996
1033
|
AND fire_at > datetime('now')
|
|
997
1034
|
LIMIT 1
|
|
@@ -1007,6 +1044,7 @@ class Database:
|
|
|
1007
1044
|
return await self._fetch_one("""
|
|
1008
1045
|
SELECT * FROM playlist_schedules
|
|
1009
1046
|
WHERE is_active = 1
|
|
1047
|
+
AND lock_disabled = 0
|
|
1010
1048
|
AND datetime(fire_at, '-' || pre_fire_lock_minutes || ' minutes') <= datetime('now')
|
|
1011
1049
|
AND fire_at > datetime('now')
|
|
1012
1050
|
ORDER BY fire_at
|
|
@@ -34,13 +34,16 @@ 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}")
|
|
@@ -53,6 +56,7 @@ async def fire_schedule(*, schedule_id: int, api_gate, db, shadow, ws_manager):
|
|
|
53
56
|
is_immutable=playlist.get("is_immutable", False),
|
|
54
57
|
started_at=now.isoformat(),
|
|
55
58
|
estimated_end_at=(now + timedelta(seconds=total_duration)).isoformat(),
|
|
59
|
+
last_item_uid=last_item_uid,
|
|
56
60
|
)
|
|
57
61
|
|
|
58
62
|
# 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
|
|
|
@@ -127,3 +127,26 @@ async def clear_active(request: Request, user: dict = Depends(require_admin)):
|
|
|
127
127
|
db = request.app.state.db
|
|
128
128
|
await db.clear_active_schedule()
|
|
129
129
|
return {"success": True}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@router.post("/unlock")
|
|
133
|
+
async def unlock(request: Request, user: dict = Depends(require_admin)):
|
|
134
|
+
"""Lift the currently-active pay-to-play lock without deleting the schedule.
|
|
135
|
+
|
|
136
|
+
Targets the in-progress scheduled-event lock first (keeps the event banner
|
|
137
|
+
and any recurring schedule armed); otherwise lifts an active pre-fire lock
|
|
138
|
+
for its current occurrence only (a recurring schedule re-locks on its next
|
|
139
|
+
firing).
|
|
140
|
+
"""
|
|
141
|
+
db = request.app.state.db
|
|
142
|
+
|
|
143
|
+
if await db.is_event_lock_active():
|
|
144
|
+
await db.disable_active_lock()
|
|
145
|
+
return {"success": True, "lifted": "event"}
|
|
146
|
+
|
|
147
|
+
prefire = await db.get_active_pre_fire_lock()
|
|
148
|
+
if prefire:
|
|
149
|
+
await db.update_schedule(prefire["id"], lock_disabled=1)
|
|
150
|
+
return {"success": True, "lifted": "pre_fire"}
|
|
151
|
+
|
|
152
|
+
return {"success": True, "lifted": None}
|
|
@@ -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)
|
{kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/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.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
@@ -42,11 +42,23 @@ async function loadActive() {
|
|
|
42
42
|
if (!a || !a.schedule_id) { banner.classList.add('hidden'); return; }
|
|
43
43
|
const name = playlistMap[a.playlist_id] || `Playlist #${a.playlist_id}`;
|
|
44
44
|
banner.classList.remove('hidden');
|
|
45
|
+
const locked = a.is_immutable && !a.lock_disabled;
|
|
45
46
|
banner.innerHTML = `
|
|
46
47
|
<strong>Active schedule running:</strong> ${escapeHtml(name)}
|
|
47
48
|
${a.is_immutable ? '<span class="badge badge-warn">Immutable</span>' : ''}
|
|
49
|
+
${locked ? '<span class="badge badge-warn">Pay-to-play locked</span>' : '<span class="badge">Unlocked</span>'}
|
|
48
50
|
${a.estimated_end_at ? `<div class="muted">Ends ~${formatLocalDateTime(a.estimated_end_at)}</div>` : ''}
|
|
49
|
-
<div style="margin-top:0.5rem;"
|
|
51
|
+
<div style="margin-top:0.5rem;">
|
|
52
|
+
${locked ? '<button class="btn btn-sm" onclick="unlockNow()">Unlock now</button> ' : ''}
|
|
53
|
+
<button class="btn btn-sm btn-danger" onclick="clearActive()">Clear Active</button>
|
|
54
|
+
</div>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function unlockNow() {
|
|
58
|
+
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;
|
|
59
|
+
const resp = await fetch('/admin/schedules/unlock', {method: 'POST'});
|
|
60
|
+
showToast(resp.ok ? 'Lock lifted' : 'Failed', resp.ok ? 'success' : 'error');
|
|
61
|
+
loadSchedules();
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
async function clearActive() {
|
|
@@ -69,11 +81,16 @@ async function loadSchedules() {
|
|
|
69
81
|
<tr><th>Label</th><th>Playlist</th><th>Fires</th><th>Lock</th><th>Status</th><th></th></tr>
|
|
70
82
|
${rows.map(s => {
|
|
71
83
|
const fired = s.fired_at ? 'fired' : (new Date(s.fire_at).getTime() < now ? 'past' : 'pending');
|
|
84
|
+
const fireMs = new Date(s.fire_at).getTime();
|
|
85
|
+
const lockMin = s.pre_fire_lock_minutes ?? 15;
|
|
86
|
+
const inPreFire = s.is_active && !isNaN(fireMs)
|
|
87
|
+
&& now >= fireMs - lockMin * 60000 && now < fireMs;
|
|
88
|
+
const preFireLocked = inPreFire && !s.lock_disabled;
|
|
72
89
|
return `<tr>
|
|
73
90
|
<td>${escapeHtml(s.label)}${s.is_recurring ? ' <span class="badge">↻</span>' : ''}</td>
|
|
74
91
|
<td>${escapeHtml(playlistMap[s.playlist_id] || ('#' + s.playlist_id))}</td>
|
|
75
92
|
<td>${formatLocalDateTime(s.fire_at)}</td>
|
|
76
|
-
<td>${s.
|
|
93
|
+
<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
94
|
<td><span class="job-status job-status-${fired === 'pending' ? 'running' : (fired === 'fired' ? 'completed' : 'cancelled')}">${s.is_active ? fired : 'disabled'}</span></td>
|
|
78
95
|
<td class="row-actions">
|
|
79
96
|
<button class="btn btn-sm" onclick="fireNow(${s.id}, '${escapeHtml(s.label)}')">Fire Now</button>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "kryten-webqueue"
|
|
3
|
-
version = "0.9.
|
|
3
|
+
version = "0.9.3"
|
|
4
4
|
description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -24,6 +24,7 @@ dependencies = [
|
|
|
24
24
|
"openpyxl>=3.1",
|
|
25
25
|
"requests>=2.31",
|
|
26
26
|
"pyyaml>=6.0",
|
|
27
|
+
"python-slugify>=8.0",
|
|
27
28
|
]
|
|
28
29
|
|
|
29
30
|
[project.optional-dependencies]
|
|
@@ -0,0 +1,170 @@
|
|
|
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
|
|
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.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/_common.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/enrichmeta.py
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/enrichtv.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/fetchurls.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/ytpipe/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/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
|
{kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/catalog/item_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.1 → kryten_webqueue-0.9.3}/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
|