kryten-webqueue 0.9.3__tar.gz → 0.9.5__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.3 → kryten_webqueue-0.9.5}/CHANGELOG.md +17 -0
  2. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/catalog/db.py +6 -0
  4. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/playlists/fire.py +23 -0
  5. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/routes/admin_schedules.py +1 -0
  6. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/routes/pages.py +4 -0
  7. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/static/css/main.css +15 -4
  8. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/templates/admin/schedules.html +12 -1
  9. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/templates/base.html +1 -1
  10. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/pyproject.toml +1 -1
  11. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/tests/test_phase4_live_fixes.py +80 -0
  12. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/.github/workflows/python-publish.yml +0 -0
  13. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/.github/workflows/release.yml +0 -0
  14. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/.gitignore +0 -0
  15. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/README.md +0 -0
  16. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/config.example.json +0 -0
  17. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/deploy/kryten-webqueue.service +0 -0
  18. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/deploy/nginx-queue.conf +0 -0
  19. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/docs/IMPLEMENTATION_SPEC.md +0 -0
  20. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/docs/IMPL_API_GATE.md +0 -0
  21. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/docs/IMPL_ECONOMY.md +0 -0
  22. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/docs/IMPL_KRYTEN_PY.md +0 -0
  23. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/docs/IMPL_ROBOT.md +0 -0
  24. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/docs/PRE_PLAN_GAPS.md +0 -0
  25. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/docs/PRODUCT_PLAN.md +0 -0
  26. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  27. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/__init__.py +0 -0
  28. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/__main__.py +0 -0
  29. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/api_gate/__init__.py +0 -0
  30. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/api_gate/client.py +0 -0
  31. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/app.py +0 -0
  32. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/auth/__init__.py +0 -0
  33. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/auth/otp.py +0 -0
  34. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/auth/rate_limit.py +0 -0
  35. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/auth/session.py +0 -0
  36. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/catalog/__init__.py +0 -0
  37. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/catalog/images.py +0 -0
  38. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/catalog/mediacms.py +0 -0
  39. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/catalog/sync.py +0 -0
  40. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/config.py +0 -0
  41. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/integrations/__init__.py +0 -0
  42. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  43. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  44. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  45. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  46. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  47. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  48. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  49. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  50. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/jobs/__init__.py +0 -0
  51. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/jobs/manager.py +0 -0
  52. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/jobs/tasks.py +0 -0
  53. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/playlists/__init__.py +0 -0
  54. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/playlists/importer.py +0 -0
  55. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/playlists/scheduler.py +0 -0
  56. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/queue/__init__.py +0 -0
  57. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/queue/ordering.py +0 -0
  58. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/queue/poller.py +0 -0
  59. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/queue/shadow.py +0 -0
  60. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/routes/__init__.py +0 -0
  61. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/routes/admin_catalog.py +0 -0
  62. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/routes/admin_jobs.py +0 -0
  63. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/routes/admin_playlists.py +0 -0
  64. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/routes/admin_queue.py +0 -0
  65. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/routes/auth.py +0 -0
  66. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/routes/catalog.py +0 -0
  67. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/routes/queue.py +0 -0
  68. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/routes/user.py +0 -0
  69. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/static/js/main.js +0 -0
  70. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/templates/admin/index.html +0 -0
  71. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/templates/admin/playlists.html +0 -0
  72. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  73. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/templates/auth/login.html +0 -0
  74. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/templates/catalog/browse.html +0 -0
  75. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  76. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  77. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/templates/queue/index.html +0 -0
  78. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/templates/user/dashboard.html +0 -0
  79. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/ws/__init__.py +0 -0
  80. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/ws/handler.py +0 -0
  81. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/kryten_webqueue/ws/manager.py +0 -0
  82. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/tests/__init__.py +0 -0
  83. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/tests/test_phase1.py +0 -0
  84. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/tests/test_phase2_jobs.py +0 -0
  85. {kryten_webqueue-0.9.3 → kryten_webqueue-0.9.5}/tests/test_phase3_jobs.py +0 -0
@@ -6,6 +6,23 @@ 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.5] — 2026-06-11
10
+
11
+ ### Changed
12
+
13
+ - No code changes — version bump only, to refresh the PyPI release index.
14
+
15
+ ## [0.9.4] — 2026-06-11
16
+
17
+ ### Added
18
+
19
+ - **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.
20
+ - **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.
21
+
22
+ ### Fixed
23
+
24
+ - **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.
25
+
9
26
  ## [0.9.3] — 2026-06-11
10
27
 
11
28
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.9.3
3
+ Version: 0.9.5
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
@@ -284,6 +284,12 @@ MIGRATIONS = [
284
284
  ALTER TABLE active_schedule ADD COLUMN last_item_uid INTEGER;
285
285
  ALTER TABLE active_schedule ADD COLUMN lock_disabled INTEGER NOT NULL DEFAULT 0;
286
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
+ """,
287
293
  ]
288
294
 
289
295
 
@@ -48,6 +48,29 @@ async def fire_schedule(*, schedule_id: int, api_gate, db, shadow, ws_manager):
48
48
  except Exception as e:
49
49
  logger.warning(f"Schedule fire: failed to add {item['media_id']}: {e}")
50
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
+
51
74
  # Update active schedule
52
75
  now = datetime.now(UTC)
53
76
  await db.set_active_schedule(
@@ -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
  )
@@ -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.
@@ -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
 
@@ -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
  }
@@ -88,7 +90,7 @@ async function loadSchedules() {
88
90
  const preFireLocked = inPreFire && !s.lock_disabled;
89
91
  return `<tr>
90
92
  <td>${escapeHtml(s.label)}${s.is_recurring ? ' <span class="badge">↻</span>' : ''}</td>
91
- <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>
92
94
  <td>${formatLocalDateTime(s.fire_at)}</td>
93
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>
94
96
  <td><span class="job-status job-status-${fired === 'pending' ? 'running' : (fired === 'fired' ? 'completed' : 'cancelled')}">${s.is_active ? fired : 'disabled'}</span></td>
@@ -106,6 +108,11 @@ function scheduleForm(s) {
106
108
  s = s || {};
107
109
  const opts = Object.entries(playlistMap).map(([id, name]) =>
108
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('');
109
116
  // fire_at → datetime-local value (local tz)
110
117
  let dtLocal = '';
111
118
  if (s.fire_at) {
@@ -116,6 +123,8 @@ function scheduleForm(s) {
116
123
  <h3>${s.id ? 'Edit' : 'New'} Schedule</h3>
117
124
  <label class="field"><span>Label</span><input type="text" id="s-label" value="${escapeHtml(s.label || '')}"></label>
118
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>
119
128
  <label class="field"><span>Fire at</span><input type="datetime-local" id="s-fireat" value="${dtLocal}"></label>
120
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>
121
130
  <label class="check"><input type="checkbox" id="s-recur" ${s.is_recurring ? 'checked' : ''}> Recurring</label>
@@ -151,6 +160,8 @@ function collectSchedule() {
151
160
  is_recurring: document.getElementById('s-recur').checked,
152
161
  rrule: document.getElementById('s-rrule').value.trim() || null,
153
162
  };
163
+ const fallbackVal = document.getElementById('s-fallback').value;
164
+ body.fallback_playlist_id = fallbackVal ? parseInt(fallbackVal, 10) : null;
154
165
  const active = document.getElementById('s-active');
155
166
  if (active) body.is_active = active.checked;
156
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.3"
3
+ version = "0.9.5"
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"
@@ -168,3 +168,83 @@ async def test_pre_fire_lock_can_be_disabled(db):
168
168
 
169
169
  await db.update_schedule(sid, lock_disabled=1)
170
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"]