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.
Files changed (85) hide show
  1. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/CHANGELOG.md +25 -0
  2. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/catalog/db.py +48 -4
  4. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/playlists/fire.py +28 -1
  5. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/playlists/scheduler.py +2 -2
  6. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/queue/ordering.py +20 -2
  7. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/queue/shadow.py +95 -8
  8. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/admin_schedules.py +24 -0
  9. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/pages.py +4 -0
  10. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/queue.py +25 -6
  11. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/static/css/main.css +15 -4
  12. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/admin/playlists.html +18 -1
  13. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/admin/schedules.html +31 -3
  14. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/base.html +1 -1
  15. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/pyproject.toml +1 -1
  16. kryten_webqueue-0.9.4/tests/test_phase4_live_fixes.py +250 -0
  17. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/.github/workflows/python-publish.yml +0 -0
  18. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/.github/workflows/release.yml +0 -0
  19. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/.gitignore +0 -0
  20. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/README.md +0 -0
  21. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/config.example.json +0 -0
  22. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/deploy/kryten-webqueue.service +0 -0
  23. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/deploy/nginx-queue.conf +0 -0
  24. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/IMPLEMENTATION_SPEC.md +0 -0
  25. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/IMPL_API_GATE.md +0 -0
  26. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/IMPL_ECONOMY.md +0 -0
  27. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/IMPL_KRYTEN_PY.md +0 -0
  28. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/IMPL_ROBOT.md +0 -0
  29. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/PRE_PLAN_GAPS.md +0 -0
  30. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/PRODUCT_PLAN.md +0 -0
  31. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  32. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/__init__.py +0 -0
  33. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/__main__.py +0 -0
  34. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/api_gate/__init__.py +0 -0
  35. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/api_gate/client.py +0 -0
  36. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/app.py +0 -0
  37. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/auth/__init__.py +0 -0
  38. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/auth/otp.py +0 -0
  39. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/auth/rate_limit.py +0 -0
  40. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/auth/session.py +0 -0
  41. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/catalog/__init__.py +0 -0
  42. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/catalog/images.py +0 -0
  43. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/catalog/mediacms.py +0 -0
  44. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/catalog/sync.py +0 -0
  45. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/config.py +0 -0
  46. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/__init__.py +0 -0
  47. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  48. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  49. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  50. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  51. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  52. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  53. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  54. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  55. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/jobs/__init__.py +0 -0
  56. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/jobs/manager.py +0 -0
  57. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/jobs/tasks.py +0 -0
  58. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/playlists/__init__.py +0 -0
  59. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/playlists/importer.py +0 -0
  60. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/queue/__init__.py +0 -0
  61. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/queue/poller.py +0 -0
  62. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/__init__.py +0 -0
  63. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/admin_catalog.py +0 -0
  64. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/admin_jobs.py +0 -0
  65. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/admin_playlists.py +0 -0
  66. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/admin_queue.py +0 -0
  67. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/auth.py +0 -0
  68. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/catalog.py +0 -0
  69. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/routes/user.py +0 -0
  70. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/static/js/main.js +0 -0
  71. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/admin/index.html +0 -0
  72. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  73. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/auth/login.html +0 -0
  74. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/catalog/browse.html +0 -0
  75. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  76. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  77. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/queue/index.html +0 -0
  78. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/templates/user/dashboard.html +0 -0
  79. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/ws/__init__.py +0 -0
  80. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/ws/handler.py +0 -0
  81. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/kryten_webqueue/ws/manager.py +0 -0
  82. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/tests/__init__.py +0 -0
  83. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/tests/test_phase1.py +0 -0
  84. {kryten_webqueue-0.9.2 → kryten_webqueue-0.9.4}/tests/test_phase2_jobs.py +0 -0
  85. {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).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.9.2
3
+ Version: 0.9.4
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,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 (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],
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 = 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
 
@@ -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 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)
@@ -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: flex;
1060
- gap: 0.4rem;
1061
- justify-content: flex-end;
1062
- flex-wrap: wrap;
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
 
@@ -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>
@@ -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;"><button class="btn btn-sm btn-danger" onclick="clearActive()">Clear Active</button></div>`;
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.pre_fire_lock_minutes ?? 15}m</td>
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>&copy; Channel-Z — Powered by <a href="https://github.com/grobertson/kryten-webqueue" target="_blank" rel="noopener">kryten-webqueue</a></p>
38
+ <p>&copy; 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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.9.2"
3
+ version = "0.9.4"
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,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"]