kryten-webqueue 0.17.0__tar.gz → 0.19.0__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 (105) hide show
  1. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/CHANGELOG.md +20 -0
  2. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/queue/shadow.py +60 -0
  4. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/static/css/main.css +33 -13
  5. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/templates/admin/index.html +48 -5
  6. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/templates/admin/schedules.html +15 -0
  7. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/templates/user/dashboard.html +56 -28
  8. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/pyproject.toml +1 -1
  9. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/tests/test_phase4_live_fixes.py +46 -0
  10. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/.github/workflows/python-publish.yml +0 -0
  11. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/.github/workflows/release.yml +0 -0
  12. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/.gitignore +0 -0
  13. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/README.md +0 -0
  14. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/config.example.json +0 -0
  15. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/deploy/kryten-webqueue.service +0 -0
  16. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/deploy/nginx-queue.conf +0 -0
  17. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  18. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/docs/IMPL_API_GATE.md +0 -0
  19. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/docs/IMPL_ECONOMY.md +0 -0
  20. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  21. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/docs/IMPL_ROBOT.md +0 -0
  22. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  23. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/docs/PRE_PLAN_GAPS.md +0 -0
  24. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/docs/PRODUCT_PLAN.md +0 -0
  25. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  26. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/docs/UX_POLISH_PLAN.md +0 -0
  27. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/__init__.py +0 -0
  28. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/__main__.py +0 -0
  29. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  30. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/api_gate/client.py +0 -0
  31. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/app.py +0 -0
  32. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/auth/__init__.py +0 -0
  33. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/auth/otp.py +0 -0
  34. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  35. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/auth/session.py +0 -0
  36. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/catalog/__init__.py +0 -0
  37. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/catalog/db.py +0 -0
  38. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/catalog/images.py +0 -0
  39. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  40. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/catalog/sync.py +0 -0
  41. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/config.py +0 -0
  42. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/integrations/__init__.py +0 -0
  43. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  44. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  45. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  46. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  47. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  48. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  49. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  50. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  51. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/jobs/__init__.py +0 -0
  52. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  53. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/jobs/manager.py +0 -0
  54. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/jobs/tasks.py +0 -0
  55. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/logging_config.py +0 -0
  56. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/playlists/__init__.py +0 -0
  57. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  58. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/playlists/fire.py +0 -0
  59. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/playlists/importer.py +0 -0
  60. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/playlists/ordering.py +0 -0
  61. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  62. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/promos/__init__.py +0 -0
  63. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/promos/director.py +0 -0
  64. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/queue/__init__.py +0 -0
  65. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/queue/ordering.py +0 -0
  66. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/queue/poller.py +0 -0
  67. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/queue/presence.py +0 -0
  68. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/routes/__init__.py +0 -0
  69. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  70. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  71. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  72. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  73. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  74. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  75. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/routes/auth.py +0 -0
  76. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/routes/catalog.py +0 -0
  77. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/routes/pages.py +0 -0
  78. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/routes/queue.py +0 -0
  79. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/routes/user.py +0 -0
  80. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/static/js/main.js +0 -0
  81. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  82. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  83. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  84. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/templates/auth/login.html +0 -0
  85. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/templates/base.html +0 -0
  86. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  87. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  88. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  89. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/templates/queue/index.html +0 -0
  90. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/ws/__init__.py +0 -0
  91. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/ws/handler.py +0 -0
  92. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/kryten_webqueue/ws/manager.py +0 -0
  93. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/tests/__init__.py +0 -0
  94. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/tests/test_config_persistence.py +0 -0
  95. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/tests/test_fetchurls_sharepoint.py +0 -0
  96. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/tests/test_phase1.py +0 -0
  97. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/tests/test_phase2_jobs.py +0 -0
  98. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/tests/test_phase3_jobs.py +0 -0
  99. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/tests/test_playlist_import.py +0 -0
  100. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/tests/test_presence_refund.py +0 -0
  101. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/tests/test_promo_director.py +0 -0
  102. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/tests/test_promo_pool_exclusion.py +0 -0
  103. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/tests/test_queue_announce.py +0 -0
  104. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/tests/test_save_results_to_playlist.py +0 -0
  105. {kryten_webqueue-0.17.0 → kryten_webqueue-0.19.0}/tests/test_search_facets.py +0 -0
@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.19.0] — 2026-06-17
10
+
11
+ ### Changed
12
+
13
+ - **Z-Coin dashboard reorganized into tabs.** The account page was overcrowded with three side-by-side columns. It's now a widened account card (balance, rank, progress, perks) beside a tabbed container with **Queue History**, **Recent Transactions**, and a new **Vanity Items** tab. Each tab lazy-loads its data on first view; the vanity greeting/color editors moved out of the cramped left column into their own roomier tab. Collapses to a single column on narrow screens.
14
+
15
+ [0.19.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.19.0
16
+
17
+ ## [0.18.0] — 2026-06-17
18
+
19
+ ### Fixed
20
+
21
+ - **Active scheduled event lingered on the admin page after it ended.** The `active_schedule` row was only removed by the manual "Clear Active Schedule" button — the lock auto-lifted but the row (and its banner) stayed. The queue shadow now clears it automatically on the next poll once the event is genuinely over, via two signals: the last scheduled item has left the queue (event temp items auto-remove after playing), or the estimated end is more than 5 minutes in the past (safety net for a missed boundary or a restart mid-event). The schedules page also hides a well-past banner immediately and re-checks every 15s.
22
+
23
+ ### Added
24
+
25
+ - **Live admin dashboard.** The admin page now subscribes to the same `/ws` feed as the public queue, so the queue item count and now-playing update without a reload. Job status refreshes every 5s while the tab is visible (jobs are DB-polled, not broadcast), and a fired schedule triggers an immediate jobs refresh.
26
+
27
+ [0.18.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.18.0
28
+
9
29
  ## [0.17.0] — 2026-06-17
10
30
 
11
31
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.17.0
3
+ Version: 0.19.0
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
@@ -23,6 +23,19 @@ def _to_seconds(value) -> float:
23
23
  return 0.0
24
24
 
25
25
 
26
+ def _parse_iso(value) -> datetime | None:
27
+ """Parse an ISO-8601 timestamp to an aware UTC datetime, or None."""
28
+ if not value:
29
+ return None
30
+ try:
31
+ dt = datetime.fromisoformat(str(value))
32
+ except (ValueError, TypeError):
33
+ return None
34
+ if dt.tzinfo is None:
35
+ dt = dt.replace(tzinfo=UTC)
36
+ return dt
37
+
38
+
26
39
  class QueueShadow:
27
40
  """Local mirror of the CyTube playlist state."""
28
41
 
@@ -116,6 +129,7 @@ class QueueShadow:
116
129
  self._items = new_items
117
130
  await self._recalculate_estimated_starts()
118
131
  await self._maybe_lift_event_lock()
132
+ await self._maybe_expire_active_schedule()
119
133
 
120
134
  async def _maybe_lift_event_lock(self):
121
135
  """Auto-lift a scheduled-event lock once its last item begins playing.
@@ -142,6 +156,52 @@ class QueueShadow:
142
156
  except (TypeError, ValueError):
143
157
  return
144
158
 
159
+ async def _maybe_expire_active_schedule(self):
160
+ """Clear the active-schedule row once the event is genuinely over.
161
+
162
+ The lock auto-lifts when the last scheduled item *starts* (see
163
+ :meth:`_maybe_lift_event_lock`), but the row itself used to linger so the
164
+ admin banner kept showing a finished event. This clears it via two
165
+ signals:
166
+
167
+ * **Event-driven (primary):** the last scheduled item has left the queue
168
+ (event items are temp and auto-remove after playing), and something is
169
+ still playing — so the event has played out.
170
+ * **Time safety net:** the estimated end is well in the past (covers a
171
+ missed boundary, a restart mid-event, or ``last_item_uid`` never being
172
+ captured).
173
+ """
174
+ active = await self._db.get_active_schedule()
175
+ if not active:
176
+ return
177
+
178
+ # Event-driven: last scheduled item is gone from the queue.
179
+ last_uid = active.get("last_item_uid")
180
+ if last_uid is not None and self._now_playing is not None:
181
+ try:
182
+ last_uid_int = int(last_uid)
183
+ except (TypeError, ValueError):
184
+ last_uid_int = None
185
+ if last_uid_int is not None:
186
+ present = False
187
+ for it in self._items:
188
+ try:
189
+ if int(it.get("uid")) == last_uid_int:
190
+ present = True
191
+ break
192
+ except (TypeError, ValueError):
193
+ continue
194
+ if not present:
195
+ await self._db.clear_active_schedule()
196
+ logger.info("Active schedule cleared: last scheduled item has played out")
197
+ return
198
+
199
+ # Time safety net: estimated end well in the past.
200
+ end_dt = _parse_iso(active.get("estimated_end_at"))
201
+ if end_dt is not None and datetime.now(UTC) > end_dt + timedelta(minutes=5):
202
+ await self._db.clear_active_schedule()
203
+ logger.info("Active schedule cleared: estimated end passed (stale row)")
204
+
145
205
  def _now_playing_index(self) -> int | None:
146
206
  """Index of the currently-playing item within ``self._items``.
147
207
 
@@ -797,20 +797,50 @@ a.np-chip {
797
797
  }
798
798
  .dashboard-grid {
799
799
  display: grid;
800
- grid-template-columns: 1fr 1fr 1fr;
800
+ grid-template-columns: minmax(280px, 360px) 1fr;
801
801
  gap: 1.5rem;
802
802
  margin-top: 1.5rem;
803
+ align-items: start;
803
804
  }
804
805
  @media (max-width: 900px) {
805
806
  .dashboard-grid {
806
807
  grid-template-columns: 1fr;
807
808
  }
808
809
  }
809
- .balance-card, .history-card, .transactions-card {
810
+ .balance-card, .history-card, .transactions-card, .dashboard-tabs-card {
810
811
  background: var(--bg-card);
811
812
  border-radius: var(--radius);
812
813
  padding: 1.5rem;
813
814
  }
815
+
816
+ /* Dashboard tabbed container */
817
+ .tabs {
818
+ display: flex;
819
+ gap: 0.25rem;
820
+ border-bottom: 1px solid var(--border);
821
+ margin-bottom: 1rem;
822
+ }
823
+ .tab-btn {
824
+ background: transparent;
825
+ border: none;
826
+ border-bottom: 2px solid transparent;
827
+ color: var(--text-secondary);
828
+ cursor: pointer;
829
+ font-size: 0.95rem;
830
+ font-weight: 600;
831
+ padding: 0.6rem 0.9rem;
832
+ margin-bottom: -1px;
833
+ }
834
+ .tab-btn:hover {
835
+ color: var(--text-primary);
836
+ }
837
+ .tab-btn.active {
838
+ color: var(--accent);
839
+ border-bottom-color: var(--accent);
840
+ }
841
+ .tab-panel[hidden] {
842
+ display: none;
843
+ }
814
844
  .balance-amount {
815
845
  font-size: 2rem;
816
846
  font-weight: 700;
@@ -911,17 +941,7 @@ a.np-chip {
911
941
  color: var(--success);
912
942
  }
913
943
 
914
- /* Vanity items (dashboard left column) */
915
- .vanity-section {
916
- margin-top: 1.25rem;
917
- padding-top: 1.25rem;
918
- border-top: 1px solid var(--border);
919
- }
920
- .vanity-section h3 {
921
- font-size: 0.85rem;
922
- color: var(--text-secondary);
923
- margin-bottom: 0.6rem;
924
- }
944
+ /* Vanity items (dashboard "Vanity Items" tab) */
925
945
  .vanity-item {
926
946
  margin-bottom: 0.85rem;
927
947
  }
@@ -234,11 +234,7 @@ async function loadAdminData() {
234
234
  // Queue status
235
235
  const qResp = await fetch('/queue/state');
236
236
  if (qResp.ok) {
237
- const state = await qResp.json();
238
- document.getElementById('queue-status').innerHTML = `
239
- <p>Items in queue: ${(state.items || []).length}</p>
240
- <p>Now playing: ${state.now_playing ? escapeHtml(state.now_playing.title || 'Unknown') : 'Nothing'}</p>
241
- `;
237
+ renderQueueStatus(await qResp.json());
242
238
  }
243
239
 
244
240
  // Sync logs
@@ -265,6 +261,46 @@ async function loadAdminData() {
265
261
  }
266
262
  }
267
263
 
264
+ // Render the live queue-status block. Shared by the initial load and the
265
+ // WebSocket so the count + now-playing stay current without a reload.
266
+ function renderQueueStatus(state) {
267
+ const el = document.getElementById('queue-status');
268
+ if (!el) return;
269
+ const np = state && state.now_playing;
270
+ el.innerHTML = `
271
+ <p>Items in queue: ${((state && state.items) || []).length}</p>
272
+ <p>Now playing: ${np ? escapeHtml(np.title || 'Unknown') : 'Nothing'}</p>
273
+ `;
274
+ }
275
+
276
+ // Subscribe to the same /ws feed the public queue page uses, so queue size and
277
+ // now-playing update live. Jobs are DB-polled (below) since they aren't
278
+ // broadcast.
279
+ let adminWs = null;
280
+ let adminWsReconnect = null;
281
+ function connectAdminWebSocket() {
282
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
283
+ adminWs = new WebSocket(`${proto}//${location.host}/ws`);
284
+ adminWs.onmessage = (event) => {
285
+ let msg;
286
+ try { msg = JSON.parse(event.data); } catch (e) { return; }
287
+ if (msg.type === 'queue_state' || msg.type === 'queue_update') {
288
+ renderQueueStatus(msg.data);
289
+ } else if (msg.type === 'schedule_fired') {
290
+ showToast(`Scheduled playlist loaded: ${msg.data.playlist_name}`);
291
+ loadJobs();
292
+ }
293
+ };
294
+ adminWs.onclose = () => {
295
+ adminWsReconnect = setTimeout(connectAdminWebSocket, 3000);
296
+ };
297
+ setInterval(() => {
298
+ if (adminWs && adminWs.readyState === WebSocket.OPEN) {
299
+ adminWs.send(JSON.stringify({type: 'ping'}));
300
+ }
301
+ }, 30000);
302
+ }
303
+
268
304
  function escapeHtml(str) {
269
305
  const div = document.createElement('div');
270
306
  div.textContent = str;
@@ -294,5 +330,12 @@ function summarizeRunDetail(detail) {
294
330
 
295
331
  loadAdminData();
296
332
  loadJobs();
333
+ connectAdminWebSocket();
334
+
335
+ // Jobs are DB-polled (not broadcast), so refresh them periodically while the
336
+ // tab is visible to reflect running/finished status without a reload.
337
+ setInterval(() => {
338
+ if (document.visibilityState === 'visible') loadJobs();
339
+ }, 5000);
297
340
  </script>
298
341
  {% endblock %}
@@ -42,6 +42,15 @@ async function loadActive() {
42
42
  if (!resp.ok) { banner.classList.add('hidden'); return; }
43
43
  const a = await resp.json();
44
44
  if (!a || !a.schedule_id) { banner.classList.add('hidden'); return; }
45
+ // Treat a well-past estimated end as ended even before the backend clears
46
+ // the row on its next poll, so the banner never lingers.
47
+ if (a.estimated_end_at) {
48
+ const end = new Date(a.estimated_end_at);
49
+ if (!isNaN(end) && (Date.now() - end.getTime()) > 5 * 60 * 1000) {
50
+ banner.classList.add('hidden');
51
+ return;
52
+ }
53
+ }
45
54
  const name = playlistMap[a.playlist_id] || `Playlist #${a.playlist_id}`;
46
55
  banner.classList.remove('hidden');
47
56
  const locked = a.is_immutable && !a.lock_disabled;
@@ -203,5 +212,11 @@ function showModal(html) {
203
212
  function closeModal() { const m = document.getElementById('admin-modal'); if (m) m.remove(); }
204
213
 
205
214
  loadSchedules();
215
+
216
+ // Keep the active-event banner fresh without a reload: re-check every 15s while
217
+ // the tab is visible (the backend clears the row once the event plays out).
218
+ setInterval(() => {
219
+ if (document.visibilityState === 'visible') loadActive();
220
+ }, 15000);
206
221
  </script>
207
222
  {% endblock %}
@@ -10,7 +10,7 @@
10
10
  </div>
11
11
 
12
12
  <div class="dashboard-grid">
13
- <!-- LEFT: Account -->
13
+ <!-- LEFT: Account (widened) -->
14
14
  <div class="balance-card">
15
15
  <h2>Z Coin Balance</h2>
16
16
  <p class="balance-amount" id="balance-amount">Loading…</p>
@@ -18,9 +18,37 @@
18
18
 
19
19
  <div class="account-rank" id="account-rank"></div>
20
20
  <div class="account-perks" id="account-perks"></div>
21
+ </div>
22
+
23
+ <!-- RIGHT: Tabbed container -->
24
+ <div class="dashboard-tabs-card">
25
+ <div class="tabs" role="tablist">
26
+ <button class="tab-btn active" data-tab="queue" role="tab" aria-selected="true">Queue History</button>
27
+ <button class="tab-btn" data-tab="transactions" role="tab" aria-selected="false">Recent Transactions</button>
28
+ <button class="tab-btn" data-tab="vanity" role="tab" aria-selected="false">Vanity Items</button>
29
+ </div>
30
+
31
+ <div class="tab-panel" id="tab-queue" role="tabpanel">
32
+ <div id="queue-history">
33
+ <p class="empty-state">Loading…</p>
34
+ </div>
35
+ <div class="history-pager" id="queue-pager"></div>
36
+ </div>
21
37
 
22
- <div class="vanity-section" id="vanity-section">
23
- <h3>Vanity</h3>
38
+ <div class="tab-panel" id="tab-transactions" role="tabpanel" hidden>
39
+ <div class="tx-toggle" id="tx-toggle">
40
+ <button class="tx-toggle-btn active" data-filter="all">All</button>
41
+ <button class="tx-toggle-btn" data-filter="credit">Credits</button>
42
+ <button class="tx-toggle-btn" data-filter="debit">Debits</button>
43
+ </div>
44
+ <div id="transactions">
45
+ <p class="empty-state">Loading…</p>
46
+ </div>
47
+ <div class="tx-pager" id="tx-pager"></div>
48
+ </div>
49
+
50
+ <div class="tab-panel" id="tab-vanity" role="tabpanel" hidden>
51
+ <p class="muted">Personalize how the bot greets you and your chat color. Each update is a one-time purchase.</p>
24
52
  <div class="vanity-item">
25
53
  <div class="vanity-row">
26
54
  <span class="vanity-label">Greeting</span>
@@ -37,29 +65,6 @@
37
65
  </div>
38
66
  </div>
39
67
  </div>
40
-
41
- <!-- MIDDLE: Queue history -->
42
- <div class="history-card">
43
- <h2>Queue History</h2>
44
- <div id="queue-history">
45
- <p class="empty-state">Loading…</p>
46
- </div>
47
- <div class="history-pager" id="queue-pager"></div>
48
- </div>
49
-
50
- <!-- RIGHT: Transactions -->
51
- <div class="transactions-card">
52
- <h2>Recent Transactions</h2>
53
- <div class="tx-toggle" id="tx-toggle">
54
- <button class="tx-toggle-btn active" data-filter="all">All</button>
55
- <button class="tx-toggle-btn" data-filter="credit">Credits</button>
56
- <button class="tx-toggle-btn" data-filter="debit">Debits</button>
57
- </div>
58
- <div id="transactions">
59
- <p class="empty-state">Loading…</p>
60
- </div>
61
- <div class="tx-pager" id="tx-pager"></div>
62
- </div>
63
68
  </div>
64
69
  </div>
65
70
  {% endblock %}
@@ -442,8 +447,31 @@ document.querySelectorAll('.tx-toggle-btn').forEach(b => b.addEventListener('cli
442
447
  renderTransactions();
443
448
  }));
444
449
 
450
+ // Tabs: show one panel at a time; lazy-load each panel's data on first view.
451
+ const tabLoaded = { queue: false, transactions: false, vanity: false };
452
+ function loadTabOnce(tab) {
453
+ if (tabLoaded[tab]) return;
454
+ tabLoaded[tab] = true;
455
+ if (tab === 'queue') loadQueue(0);
456
+ else if (tab === 'transactions') loadTransactions(true);
457
+ // 'vanity' needs no fetch — populated by loadAccount().
458
+ }
459
+ function showTab(tab) {
460
+ document.querySelectorAll('.tab-btn').forEach(b => {
461
+ const on = b.dataset.tab === tab;
462
+ b.classList.toggle('active', on);
463
+ b.setAttribute('aria-selected', on ? 'true' : 'false');
464
+ });
465
+ document.querySelectorAll('.tab-panel').forEach(p => {
466
+ p.hidden = p.id !== `tab-${tab}`;
467
+ });
468
+ loadTabOnce(tab);
469
+ }
470
+ document.querySelectorAll('.tab-btn').forEach(b => {
471
+ b.addEventListener('click', () => showTab(b.dataset.tab));
472
+ });
473
+
445
474
  loadAccount();
446
- loadQueue(0);
447
- loadTransactions(true);
475
+ showTab('queue'); // default tab; lazy-loads queue history
448
476
  </script>
449
477
  {% endblock %}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.17.0"
3
+ version = "0.19.0"
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"
@@ -152,6 +152,52 @@ async def test_event_lock_stays_until_last_item(db):
152
152
  assert await db.is_event_lock_active() is True
153
153
 
154
154
 
155
+ # --- active-schedule auto-expiry (v0.18.0) ---
156
+
157
+ async def test_active_schedule_cleared_when_last_item_plays_out(db):
158
+ await _make_event(db, last_item_uid=3)
159
+ shadow = QueueShadow(db)
160
+ # The last scheduled item (uid=3) is still in the queue -> row kept.
161
+ await shadow.apply_poll_result(
162
+ [_polled(1), _polled(2), _polled(3)], {"uid": 2, "seconds": 100, "currentTime": 0}
163
+ )
164
+ assert (await db.get_active_schedule()) is not None
165
+ # uid=3 has now left the queue (played out, temp item removed) -> row cleared.
166
+ await shadow.apply_poll_result(
167
+ [_polled(4), _polled(5)], {"uid": 4, "seconds": 100, "currentTime": 0}
168
+ )
169
+ assert (await db.get_active_schedule()) is None
170
+
171
+
172
+ async def test_active_schedule_not_cleared_when_nothing_playing(db):
173
+ await _make_event(db, last_item_uid=3)
174
+ shadow = QueueShadow(db)
175
+ # Last item absent but nothing is playing (transient empty poll) -> keep row.
176
+ await shadow.apply_poll_result([], None)
177
+ assert (await db.get_active_schedule()) is not None
178
+
179
+
180
+ async def test_active_schedule_cleared_when_estimated_end_passed(db):
181
+ # last_item_uid is None so the event-driven path can't fire; rely on the
182
+ # time safety net with an estimated end well in the past.
183
+ pid = await db.create_saved_playlist(
184
+ name="Stale", description=None, is_immutable=True, created_by="admin"
185
+ )
186
+ sid = await db.create_schedule(
187
+ playlist_id=pid, label="Stale", fire_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
188
+ is_active=1, created_by="admin",
189
+ )
190
+ past = datetime.now(UTC) - timedelta(hours=1)
191
+ await db.set_active_schedule(
192
+ schedule_id=sid, playlist_id=pid, is_immutable=True,
193
+ started_at=(past - timedelta(hours=1)).isoformat(),
194
+ estimated_end_at=past.isoformat(), last_item_uid=None,
195
+ )
196
+ shadow = QueueShadow(db)
197
+ await shadow.apply_poll_result([_polled(1)], {"uid": 1, "seconds": 100, "currentTime": 0})
198
+ assert (await db.get_active_schedule()) is None
199
+
200
+
155
201
  # --- #4 pre-fire lock override ---
156
202
 
157
203
  async def test_pre_fire_lock_can_be_disabled(db):