kryten-webqueue 0.6.2__tar.gz → 0.6.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/CHANGELOG.md +17 -0
  2. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/queue/shadow.py +29 -8
  4. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/static/css/main.css +40 -0
  5. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/templates/auth/login.html +2 -1
  6. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/templates/queue/index.html +17 -2
  7. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/pyproject.toml +1 -1
  8. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/.github/workflows/python-publish.yml +0 -0
  9. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/.github/workflows/release.yml +0 -0
  10. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/.gitignore +0 -0
  11. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/README.md +0 -0
  12. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/config.example.json +0 -0
  13. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/deploy/kryten-webqueue.service +0 -0
  14. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/deploy/nginx-queue.conf +0 -0
  15. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/docs/IMPLEMENTATION_SPEC.md +0 -0
  16. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/docs/IMPL_API_GATE.md +0 -0
  17. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/docs/IMPL_ECONOMY.md +0 -0
  18. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/docs/IMPL_KRYTEN_PY.md +0 -0
  19. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/docs/IMPL_ROBOT.md +0 -0
  20. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/docs/PRE_PLAN_GAPS.md +0 -0
  21. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/docs/PRODUCT_PLAN.md +0 -0
  22. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/__init__.py +0 -0
  23. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/__main__.py +0 -0
  24. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/api_gate/__init__.py +0 -0
  25. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/api_gate/client.py +0 -0
  26. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/app.py +0 -0
  27. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/auth/__init__.py +0 -0
  28. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/auth/otp.py +0 -0
  29. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/auth/rate_limit.py +0 -0
  30. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/auth/session.py +0 -0
  31. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/catalog/__init__.py +0 -0
  32. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/catalog/db.py +0 -0
  33. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/catalog/images.py +0 -0
  34. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/catalog/sync.py +0 -0
  35. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/config.py +0 -0
  36. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/jobs/__init__.py +0 -0
  37. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/jobs/manager.py +0 -0
  38. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/playlists/__init__.py +0 -0
  39. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/playlists/fire.py +0 -0
  40. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/playlists/importer.py +0 -0
  41. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/playlists/scheduler.py +0 -0
  42. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/queue/__init__.py +0 -0
  43. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/queue/ordering.py +0 -0
  44. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/queue/poller.py +0 -0
  45. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/routes/__init__.py +0 -0
  46. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/routes/admin_jobs.py +0 -0
  47. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/routes/admin_playlists.py +0 -0
  48. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/routes/admin_queue.py +0 -0
  49. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/routes/admin_schedules.py +0 -0
  50. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/routes/auth.py +0 -0
  51. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/routes/catalog.py +0 -0
  52. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/routes/pages.py +0 -0
  53. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/routes/queue.py +0 -0
  54. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/routes/user.py +0 -0
  55. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/static/js/main.js +0 -0
  56. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/templates/admin/index.html +0 -0
  57. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/templates/admin/playlists.html +0 -0
  58. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  59. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/templates/admin/schedules.html +0 -0
  60. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/templates/base.html +0 -0
  61. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/templates/catalog/browse.html +0 -0
  62. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  63. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  64. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/templates/user/dashboard.html +0 -0
  65. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/ws/__init__.py +0 -0
  66. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/ws/handler.py +0 -0
  67. {kryten_webqueue-0.6.2 → kryten_webqueue-0.6.3}/kryten_webqueue/ws/manager.py +0 -0
@@ -4,7 +4,24 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  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
+ ## [0.6.3] - 2026-06-08
7
8
 
9
+ ### Fixed
10
+
11
+ - **Queue items missing title/duration for externally-queued media** — CyTube playlist items nest their metadata under a `media` key (`{uid, temp, queueby, media: {id, title, seconds, type}}`), but the shadow reconciler read flat fields, so any item not added by the running webqueue instance showed "Unknown" with a `0:00` duration. The reconciler now reads the nested `media` object (with a flat-key fallback) and backfills title/duration/media-id onto known items. Externally-queued items now also show their CyTube `queueby` as the requester.
12
+ - **Now Playing runtime showing `NaN:NaN`** — The now-playing total used the preformatted `duration` string instead of the numeric `seconds`, producing `NaN:NaN` for the total and remaining time. It now uses `seconds` (with a numeric fallback).
13
+ - **Estimated start times** — Now derived from the numeric now-playing `seconds`/`currentTime`, so the queue ETAs are correct now that item durations are read properly.
14
+
15
+ ### Added
16
+
17
+ - **Sticky, highlighted Now Playing card** — The Now Playing card is pinned below the navbar while scrolling a long queue, is highlighted with a subtle accent ring/glow, and the page auto-scrolls it into view on load.
18
+ - **Case-sensitivity notice on login** — The OTP login view now warns that the CyTube username is case-sensitive and must match exactly (e.g. `TacoBelmont` ≠ `tacobelmont`); the username field no longer auto-capitalizes or autocorrects.
19
+
20
+ ### Changed
21
+
22
+ - **Sticky navbar hardening** — Added `scroll-padding-top` (via a `--nav-height` variable) so scrolled-to content clears the sticky header.
23
+
24
+ [0.6.3]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.6.3
8
25
  ## [0.6.2] - 2026-06-07
9
26
 
10
27
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.6.2
3
+ Version: 0.6.3
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -63,20 +63,40 @@ class QueueShadow:
63
63
 
64
64
  for pos, polled in enumerate(playlist_items):
65
65
  uid = polled["uid"]
66
+ # CyTube playlist items nest the media metadata under a "media"
67
+ # key ({uid, temp, queueby, media: {id, title, seconds, type}}).
68
+ # Fall back to flat keys for forward/backward compatibility.
69
+ media = polled.get("media") if isinstance(polled.get("media"), dict) else polled
70
+ title = media.get("title") or polled.get("title") or ""
71
+ media_type = media.get("type") or polled.get("type") or "unknown"
72
+ media_id = media.get("id") or polled.get("id") or ""
73
+ duration_sec = _to_seconds(media.get("seconds", media.get("duration")))
74
+ queueby = polled.get("queueby") or None
75
+
66
76
  if uid in local_map:
67
- # Preserve local metadata, update position
68
- merged = {**local_map[uid], **polled, "position": pos}
77
+ # Preserve local metadata, update position; backfill any
78
+ # fields we never captured locally (e.g. title/duration for
79
+ # items first added by an external client or a prior run).
80
+ merged = {**local_map[uid], "position": pos}
81
+ if not merged.get("title"):
82
+ merged["title"] = title
83
+ if not merged.get("duration_sec"):
84
+ merged["duration_sec"] = duration_sec
85
+ if not merged.get("media_id"):
86
+ merged["media_id"] = media_id
87
+ if not merged.get("media_type") or merged.get("media_type") == "unknown":
88
+ merged["media_type"] = media_type
69
89
  else:
70
90
  # New item from external source
71
91
  merged = {
72
92
  "uid": uid,
73
93
  "position": pos,
74
- "title": polled.get("title", ""),
75
- "media_type": polled.get("type", "unknown"),
76
- "media_id": polled.get("id", ""),
77
- "duration_sec": _to_seconds(polled.get("duration")),
94
+ "title": title,
95
+ "media_type": media_type,
96
+ "media_id": media_id,
97
+ "duration_sec": duration_sec,
78
98
  "is_pay": False,
79
- "paid_by": None,
99
+ "paid_by": queueby,
80
100
  "tier": None,
81
101
  "z_cost": None,
82
102
  "schedule_id": None,
@@ -97,7 +117,8 @@ class QueueShadow:
97
117
  # Start from now-playing elapsed or now
98
118
  start_cursor = datetime.now(UTC)
99
119
  if self._now_playing:
100
- remaining = _to_seconds(self._now_playing.get("duration")) - _to_seconds(self._now_playing.get("currentTime"))
120
+ np_total = _to_seconds(self._now_playing.get("seconds", self._now_playing.get("duration")))
121
+ remaining = np_total - _to_seconds(self._now_playing.get("currentTime"))
101
122
  start_cursor += timedelta(seconds=max(0, remaining))
102
123
 
103
124
  for item in self._items:
@@ -15,6 +15,7 @@
15
15
  --border: #33334a;
16
16
  --radius: 8px;
17
17
  --shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
18
+ --nav-height: 4rem;
18
19
  }
19
20
 
20
21
  * {
@@ -23,6 +24,11 @@
23
24
  box-sizing: border-box;
24
25
  }
25
26
 
27
+ html {
28
+ /* Keep anchored/scrolled-to content clear of the sticky navbar. */
29
+ scroll-padding-top: calc(var(--nav-height) + 1rem);
30
+ }
31
+
26
32
  body {
27
33
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
28
34
  background: var(--bg-primary);
@@ -228,6 +234,18 @@ a:hover {
228
234
  }
229
235
  }
230
236
 
237
+ /* Keep the now-playing card pinned while scrolling a long queue. */
238
+ .now-playing-section {
239
+ position: sticky;
240
+ top: calc(var(--nav-height) + 1rem);
241
+ align-self: start;
242
+ }
243
+ @media (max-width: 900px) {
244
+ .now-playing-section {
245
+ position: static;
246
+ }
247
+ }
248
+
231
249
  .now-playing-card {
232
250
  background: var(--bg-card);
233
251
  border-radius: var(--radius);
@@ -235,6 +253,9 @@ a:hover {
235
253
  display: flex;
236
254
  gap: 1rem;
237
255
  align-items: flex-start;
256
+ /* Highlight the currently-playing item: subtle accent ring + glow. */
257
+ border: 1px solid var(--accent);
258
+ box-shadow: 0 0 0 1px rgba(108, 92, 231, 0.25), 0 6px 18px rgba(108, 92, 231, 0.18);
238
259
  }
239
260
  .np-info {
240
261
  flex: 1;
@@ -432,6 +453,25 @@ a:hover {
432
453
  .otp-sent-msg {
433
454
  color: var(--success);
434
455
  }
456
+ .auth-hint {
457
+ margin-top: 0.75rem;
458
+ padding: 0.6rem 0.85rem;
459
+ font-size: 0.85rem;
460
+ color: var(--text-secondary);
461
+ background: rgba(253, 203, 110, 0.1);
462
+ border: 1px solid var(--warning);
463
+ border-radius: var(--radius);
464
+ text-align: left;
465
+ }
466
+ .auth-hint strong {
467
+ color: var(--warning);
468
+ }
469
+ .auth-hint code {
470
+ background: var(--bg-secondary);
471
+ padding: 0.05rem 0.3rem;
472
+ border-radius: 4px;
473
+ font-size: 0.85em;
474
+ }
435
475
 
436
476
  /* User Dashboard */
437
477
  .user-header {
@@ -6,9 +6,10 @@
6
6
  <div class="auth-container">
7
7
  <h1>Login</h1>
8
8
  <p>Enter your CyTube username to receive a one-time code via PM.</p>
9
+ <p class="auth-hint"><strong>Username is case-sensitive</strong> &mdash; it must match your CyTube username exactly. For example, <code>TacoBelmont</code> is not the same as <code>tacobelmont</code>.</p>
9
10
 
10
11
  <div id="otp-request" class="auth-form">
11
- <input type="text" id="username" placeholder="CyTube username" autocomplete="username" maxlength="50">
12
+ <input type="text" id="username" placeholder="CyTube username (case-sensitive)" autocomplete="username" autocapitalize="none" autocorrect="off" spellcheck="false" maxlength="50">
12
13
  <button id="request-otp-btn" class="btn btn-primary">Send Code</button>
13
14
  </div>
14
15
 
@@ -58,6 +58,7 @@ function connectWebSocket() {
58
58
  const msg = JSON.parse(event.data);
59
59
  if (msg.type === 'queue_state' || msg.type === 'queue_update') {
60
60
  renderQueue(msg.data);
61
+ maybeInitialScroll(msg.data);
61
62
  } else if (msg.type === 'pong') {
62
63
  // keepalive ack
63
64
  } else if (msg.type === 'schedule_fired') {
@@ -80,8 +81,10 @@ function renderQueue(state) {
80
81
 
81
82
  if (state.now_playing) {
82
83
  const np = state.now_playing;
83
- const elapsed = np.currentTime || 0;
84
- const total = np.duration || 0;
84
+ const elapsed = Number(np.currentTime) || 0;
85
+ // CyTube's now-playing payload uses numeric `seconds` for the total
86
+ // runtime (`duration` is a preformatted "HH:MM:SS" string).
87
+ const total = Number(np.seconds ?? np.duration_sec ?? 0) || 0;
85
88
  const remaining = Math.max(0, total - elapsed);
86
89
  npEl.innerHTML = `
87
90
  <div class="np-cover">${coverHtml(np)}</div>
@@ -131,6 +134,17 @@ function renderQueue(state) {
131
134
  }
132
135
  }
133
136
 
137
+ // On first render with live data, bring the now-playing card into view.
138
+ let didInitialScroll = false;
139
+ function maybeInitialScroll(state) {
140
+ if (didInitialScroll || !state || !state.now_playing) return;
141
+ didInitialScroll = true;
142
+ const sec = document.querySelector('.now-playing-section');
143
+ if (sec) {
144
+ setTimeout(() => sec.scrollIntoView({behavior: 'smooth', block: 'start'}), 150);
145
+ }
146
+ }
147
+
134
148
  function formatTime(sec) {
135
149
  sec = Math.floor(sec);
136
150
  const h = Math.floor(sec / 3600);
@@ -169,6 +183,7 @@ async function initialLoad() {
169
183
  if (resp.ok) {
170
184
  const state = await resp.json();
171
185
  renderQueue(state);
186
+ maybeInitialScroll(state);
172
187
  }
173
188
  } catch (e) { /* WS will take over */ }
174
189
  }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.6.2"
3
+ version = "0.6.3"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"