kryten-webqueue 0.9.2__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.
Files changed (85) hide show
  1. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/CHANGELOG.md +14 -0
  2. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/catalog/db.py +42 -4
  4. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/playlists/fire.py +5 -1
  5. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/playlists/scheduler.py +2 -2
  6. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/queue/ordering.py +20 -2
  7. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/queue/shadow.py +95 -8
  8. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/admin_schedules.py +23 -0
  9. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/queue.py +25 -6
  10. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/admin/playlists.html +18 -1
  11. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/admin/schedules.html +19 -2
  12. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/pyproject.toml +1 -1
  13. kryten_webqueue-0.9.3/tests/test_phase4_live_fixes.py +170 -0
  14. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/.github/workflows/python-publish.yml +0 -0
  15. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/.github/workflows/release.yml +0 -0
  16. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/.gitignore +0 -0
  17. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/README.md +0 -0
  18. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/config.example.json +0 -0
  19. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/deploy/kryten-webqueue.service +0 -0
  20. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/deploy/nginx-queue.conf +0 -0
  21. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/docs/IMPLEMENTATION_SPEC.md +0 -0
  22. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/docs/IMPL_API_GATE.md +0 -0
  23. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/docs/IMPL_ECONOMY.md +0 -0
  24. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/docs/IMPL_KRYTEN_PY.md +0 -0
  25. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/docs/IMPL_ROBOT.md +0 -0
  26. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/docs/PRE_PLAN_GAPS.md +0 -0
  27. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/docs/PRODUCT_PLAN.md +0 -0
  28. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  29. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/__init__.py +0 -0
  30. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/__main__.py +0 -0
  31. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/api_gate/__init__.py +0 -0
  32. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/api_gate/client.py +0 -0
  33. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/app.py +0 -0
  34. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/auth/__init__.py +0 -0
  35. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/auth/otp.py +0 -0
  36. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/auth/rate_limit.py +0 -0
  37. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/auth/session.py +0 -0
  38. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/catalog/__init__.py +0 -0
  39. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/catalog/images.py +0 -0
  40. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/catalog/mediacms.py +0 -0
  41. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/catalog/sync.py +0 -0
  42. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/config.py +0 -0
  43. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/__init__.py +0 -0
  44. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  45. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  46. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  47. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  48. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  49. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  50. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  51. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  52. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/jobs/__init__.py +0 -0
  53. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/jobs/manager.py +0 -0
  54. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/jobs/tasks.py +0 -0
  55. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/playlists/__init__.py +0 -0
  56. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/playlists/importer.py +0 -0
  57. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/queue/__init__.py +0 -0
  58. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/queue/poller.py +0 -0
  59. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/__init__.py +0 -0
  60. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/admin_catalog.py +0 -0
  61. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/admin_jobs.py +0 -0
  62. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/admin_playlists.py +0 -0
  63. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/admin_queue.py +0 -0
  64. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/auth.py +0 -0
  65. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/catalog.py +0 -0
  66. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/pages.py +0 -0
  67. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/routes/user.py +0 -0
  68. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/static/css/main.css +0 -0
  69. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/static/js/main.js +0 -0
  70. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/admin/index.html +0 -0
  71. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  72. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/auth/login.html +0 -0
  73. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/base.html +0 -0
  74. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/catalog/browse.html +0 -0
  75. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  76. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  77. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/queue/index.html +0 -0
  78. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/templates/user/dashboard.html +0 -0
  79. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/ws/__init__.py +0 -0
  80. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/ws/handler.py +0 -0
  81. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/kryten_webqueue/ws/manager.py +0 -0
  82. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/tests/__init__.py +0 -0
  83. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/tests/test_phase1.py +0 -0
  84. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/tests/test_phase2_jobs.py +0 -0
  85. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.3}/tests/test_phase3_jobs.py +0 -0
@@ -6,6 +6,20 @@ 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
+
9
23
  ### Fixed
10
24
 
11
25
  - Added missing `python-slugify>=8.0` core dependency (required by the `fetch` job).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.9.2
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
@@ -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 (id, schedule_id, playlist_id, is_immutable, started_at, estimated_end_at) "
982
- "VALUES (1, ?, ?, ?, ?, ?)",
983
- [schedule_id, playlist_id, int(is_immutable), started_at, estimated_end_at],
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 = await db.get_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 = await db.get_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
- # Offset (seconds from now) until the head of the queue starts playing.
128
- offset = 0.0
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
- offset = max(0.0, remaining)
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 = datetime.now(UTC)
135
- for item in self._items:
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 lock
52
- if await db.is_pre_fire_lock_active():
53
- raise HTTPException(423, await _pre_fire_lock_detail(db))
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 lock
108
- if await db.is_pre_fire_lock_active():
109
- raise HTTPException(423, await _pre_fire_lock_detail(db))
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)
@@ -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>' : ''}</td>
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>
@@ -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;"><button class="btn btn-sm btn-danger" onclick="clearActive()">Clear Active</button></div>`;
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.pre_fire_lock_minutes ?? 15}m</td>
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.2"
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"
@@ -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