kryten-webqueue 0.5.1__tar.gz → 0.5.2__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 (62) hide show
  1. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/CHANGELOG.md +12 -0
  2. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/queue/ordering.py +77 -44
  4. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/pyproject.toml +1 -1
  5. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/.github/workflows/python-publish.yml +0 -0
  6. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/.github/workflows/release.yml +0 -0
  7. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/.gitignore +0 -0
  8. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/README.md +0 -0
  9. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/config.example.json +0 -0
  10. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/deploy/kryten-webqueue.service +0 -0
  11. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/deploy/nginx-queue.conf +0 -0
  12. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/docs/IMPLEMENTATION_SPEC.md +0 -0
  13. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/docs/IMPL_API_GATE.md +0 -0
  14. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/docs/IMPL_ECONOMY.md +0 -0
  15. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/docs/IMPL_KRYTEN_PY.md +0 -0
  16. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/docs/IMPL_ROBOT.md +0 -0
  17. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/docs/PRE_PLAN_GAPS.md +0 -0
  18. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/docs/PRODUCT_PLAN.md +0 -0
  19. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/__init__.py +0 -0
  20. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/__main__.py +0 -0
  21. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/api_gate/__init__.py +0 -0
  22. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/api_gate/client.py +0 -0
  23. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/app.py +0 -0
  24. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/auth/__init__.py +0 -0
  25. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/auth/otp.py +0 -0
  26. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/auth/rate_limit.py +0 -0
  27. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/auth/session.py +0 -0
  28. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/catalog/__init__.py +0 -0
  29. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/catalog/db.py +0 -0
  30. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/catalog/images.py +0 -0
  31. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/catalog/sync.py +0 -0
  32. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/config.py +0 -0
  33. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/playlists/__init__.py +0 -0
  34. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/playlists/fire.py +0 -0
  35. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/playlists/importer.py +0 -0
  36. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/playlists/scheduler.py +0 -0
  37. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/queue/__init__.py +0 -0
  38. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/queue/poller.py +0 -0
  39. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/queue/shadow.py +0 -0
  40. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/routes/__init__.py +0 -0
  41. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/routes/admin_playlists.py +0 -0
  42. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/routes/admin_queue.py +0 -0
  43. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/routes/admin_schedules.py +0 -0
  44. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/routes/auth.py +0 -0
  45. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/routes/catalog.py +0 -0
  46. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/routes/pages.py +0 -0
  47. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/routes/queue.py +0 -0
  48. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/routes/user.py +0 -0
  49. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/static/css/main.css +0 -0
  50. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/static/js/main.js +0 -0
  51. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/templates/admin/index.html +0 -0
  52. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/templates/admin/playlists.html +0 -0
  53. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  54. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/templates/admin/schedules.html +0 -0
  55. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/templates/auth/login.html +0 -0
  56. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/templates/base.html +0 -0
  57. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/templates/catalog/browse.html +0 -0
  58. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/templates/queue/index.html +0 -0
  59. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/templates/user/dashboard.html +0 -0
  60. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/ws/__init__.py +0 -0
  61. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/ws/handler.py +0 -0
  62. {kryten_webqueue-0.5.1 → kryten_webqueue-0.5.2}/kryten_webqueue/ws/manager.py +0 -0
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
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
7
 
8
+ ## [0.5.2] - 2026-06-05
9
+
10
+ ### Fixed
11
+
12
+ - **Queue items now land in the correct playlist position** — Positioning is computed relative to the currently-playing item and the persistent pay-queue list (`queue_shadow` rows with `is_pay = 1`):
13
+ - **Play Next** — moved to immediately *after* the currently-playing item (previously `prepend`, which placed it before the active item). Existing pay items shift down one position.
14
+ - **Queue** — moved to immediately after the *last* item in the persistent pay-queue list, or after the currently-playing item when no pay items exist (previously left at the end of the playlist when the pay list was empty).
15
+ - **Queue as Admin** — same target as Queue, but the item is *not* added to the persistent pay list (`is_pay = 0`).
16
+ - All paths now add to CyTube with `position="end"` and then issue a single `move` to the resolved target UID, refunding (where applicable) and removing the orphaned item if the move fails.
17
+
18
+ [0.5.2]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.5.2
19
+
8
20
  ## [0.5.1] - 2026-06-05
9
21
 
10
22
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.5.1
3
+ Version: 0.5.2
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
@@ -50,6 +50,39 @@ async def _announce_queued(api_gate, shadow, *, uid: int, title: str, username:
50
50
  logger.warning("Failed to send queue announcement", exc_info=True)
51
51
 
52
52
 
53
+ async def _now_playing_uid(api_gate, shadow) -> int | None:
54
+ """UID of the currently-playing item, preferring fresh state over the cache."""
55
+ np = None
56
+ try:
57
+ np = await api_gate.get_now_playing()
58
+ except Exception:
59
+ np = None
60
+ if not np:
61
+ np = shadow.now_playing
62
+ if not np:
63
+ return None
64
+ uid = np.get("uid")
65
+ try:
66
+ return int(uid) if uid is not None else None
67
+ except (TypeError, ValueError):
68
+ return None
69
+
70
+
71
+ def _shadow_index_after_uid(shadow, target_uid: int | None) -> int:
72
+ """Shadow list index immediately after target_uid (end of list if not found)."""
73
+ if target_uid is not None:
74
+ for idx, it in enumerate(shadow.items):
75
+ if it.get("uid") == target_uid:
76
+ return idx + 1
77
+ return len(shadow.items)
78
+
79
+
80
+ async def _move_after(api_gate, *, uid: int, target_uid: int | None) -> None:
81
+ """Move uid to immediately after target_uid. No-op when target is None."""
82
+ if target_uid is not None:
83
+ await api_gate.playlist_move(uid, target_uid)
84
+
85
+
53
86
  async def insert_pay_queue(
54
87
  *,
55
88
  api_gate,
@@ -80,16 +113,17 @@ async def insert_pay_queue(
80
113
  except httpx.HTTPStatusError as exc:
81
114
  return {"success": False, "error": f"Spend failed: {exc.response.status_code}"}
82
115
 
83
- # Find position: after last pay item, or prepend if none
116
+ # Target position: immediately after the LAST item in the persistent
117
+ # pay-queue list, or after the currently-playing item when none exist.
84
118
  last_pay_uid = await db.get_last_pay_uid()
85
- position = "end" if not last_pay_uid else str(last_pay_uid)
119
+ target_uid = last_pay_uid if last_pay_uid else await _now_playing_uid(api_gate, shadow)
86
120
 
87
- # Add to CyTube playlist
121
+ # Add to CyTube playlist (always appended; repositioned below)
88
122
  try:
89
123
  add_result = await api_gate.playlist_add(
90
124
  media_type=media_type,
91
125
  media_id=media_id,
92
- position=position,
126
+ position="end",
93
127
  )
94
128
  except httpx.HTTPStatusError as exc:
95
129
  try:
@@ -107,20 +141,19 @@ async def insert_pay_queue(
107
141
 
108
142
  uid = add_result["uid"]
109
143
 
110
- # Move after last pay UID if needed; refund + remove if positioning fails
111
- if last_pay_uid:
144
+ # Move after the target UID; refund + remove if positioning fails
145
+ try:
146
+ await _move_after(api_gate, uid=uid, target_uid=target_uid)
147
+ except httpx.HTTPStatusError:
112
148
  try:
113
- await api_gate.playlist_move(uid, last_pay_uid)
114
- except httpx.HTTPStatusError:
115
- try:
116
- await api_gate.queue_refund(username=username, request_id=request_id, reason="move_failed")
117
- except Exception:
118
- pass
119
- try:
120
- await api_gate.playlist_delete(uid)
121
- except Exception:
122
- pass
123
- return {"success": False, "error": "Failed to position item in queue"}
149
+ await api_gate.queue_refund(username=username, request_id=request_id, reason="move_failed")
150
+ except Exception:
151
+ pass
152
+ try:
153
+ await api_gate.playlist_delete(uid)
154
+ except Exception:
155
+ pass
156
+ return {"success": False, "error": "Failed to position item in queue"}
124
157
 
125
158
  # Record spend
126
159
  _ft = friendly_token if friendly_token is not None else (media_id if media_type == "cm" else None)
@@ -143,11 +176,8 @@ async def insert_pay_queue(
143
176
  "z_cost": z_cost,
144
177
  "schedule_id": None,
145
178
  }
146
- # Position after last pay
147
- if last_pay_uid:
148
- pos = await db.get_shadow_position_after(last_pay_uid)
149
- else:
150
- pos = len(shadow.items)
179
+ # Position immediately after the target UID
180
+ pos = _shadow_index_after_uid(shadow, target_uid)
151
181
  await shadow.insert_at(item, pos)
152
182
 
153
183
  # Queue history
@@ -192,7 +222,10 @@ async def insert_pay_playnext(
192
222
  except httpx.HTTPStatusError as exc:
193
223
  return {"success": False, "error": f"Spend failed: {exc.response.status_code}"}
194
224
 
195
- # Add to CyTube playlist at prepend position
225
+ # Target position: immediately after the currently-playing item.
226
+ target_uid = await _now_playing_uid(api_gate, shadow)
227
+
228
+ # Add to CyTube playlist (always appended; repositioned below)
196
229
  try:
197
230
  add_result = await api_gate.playlist_add(
198
231
  media_type=media_type,
@@ -214,9 +247,9 @@ async def insert_pay_playnext(
214
247
 
215
248
  uid = add_result["uid"]
216
249
 
217
- # Move to front; refund + remove if positioning fails
250
+ # Move to immediately after the now-playing item; refund + remove on failure
218
251
  try:
219
- await api_gate.playlist_move(uid, "prepend")
252
+ await _move_after(api_gate, uid=uid, target_uid=target_uid)
220
253
  except httpx.HTTPStatusError:
221
254
  try:
222
255
  await api_gate.queue_refund(username=username, request_id=request_id, reason="move_failed")
@@ -236,7 +269,8 @@ async def insert_pay_playnext(
236
269
  tier=tier, z_cost=z_cost,
237
270
  )
238
271
 
239
- # Update local shadow at position 0
272
+ # Update local shadow immediately after now-playing. Existing pay items
273
+ # shift down one position as insert_at re-indexes the list.
240
274
  item = {
241
275
  "uid": uid,
242
276
  "title": title,
@@ -249,7 +283,8 @@ async def insert_pay_playnext(
249
283
  "z_cost": z_cost,
250
284
  "schedule_id": None,
251
285
  }
252
- await shadow.insert_at(item, 0)
286
+ pos = _shadow_index_after_uid(shadow, target_uid)
287
+ await shadow.insert_at(item, pos)
253
288
 
254
289
  await db.add_queue_history(
255
290
  username=username, friendly_token=_ft,
@@ -280,16 +315,18 @@ async def insert_admin_queue(
280
315
  immediately after the last paid item, i.e. at the top of the free section.
281
316
  """
282
317
  async with _queue_lock:
283
- # First available non-pay slot is right after the last pay item.
318
+ # Target position: immediately after the LAST item in the persistent
319
+ # pay-queue list, or after the currently-playing item when none exist.
320
+ # The admin item itself is NOT added to the persistent pay list.
284
321
  last_pay_uid = await db.get_last_pay_uid()
285
- position = "end" if not last_pay_uid else str(last_pay_uid)
322
+ target_uid = last_pay_uid if last_pay_uid else await _now_playing_uid(api_gate, shadow)
286
323
 
287
- # Add to CyTube playlist
324
+ # Add to CyTube playlist (always appended; repositioned below)
288
325
  try:
289
326
  add_result = await api_gate.playlist_add(
290
327
  media_type=media_type,
291
328
  media_id=media_id,
292
- position=position,
329
+ position="end",
293
330
  )
294
331
  except httpx.HTTPStatusError as exc:
295
332
  return {"success": False, "error": _add_failure_reason(None, exc)}
@@ -298,16 +335,15 @@ async def insert_admin_queue(
298
335
 
299
336
  uid = add_result["uid"]
300
337
 
301
- # Move to the top of the free section if there are pay items above
302
- if last_pay_uid:
338
+ # Move after the target UID; remove the orphan if positioning fails
339
+ try:
340
+ await _move_after(api_gate, uid=uid, target_uid=target_uid)
341
+ except httpx.HTTPStatusError:
303
342
  try:
304
- await api_gate.playlist_move(uid, last_pay_uid)
305
- except httpx.HTTPStatusError:
306
- try:
307
- await api_gate.playlist_delete(uid)
308
- except Exception:
309
- pass
310
- return {"success": False, "error": "Failed to position item in queue"}
343
+ await api_gate.playlist_delete(uid)
344
+ except Exception:
345
+ pass
346
+ return {"success": False, "error": "Failed to position item in queue"}
311
347
 
312
348
  _ft = friendly_token if friendly_token is not None else (media_id if media_type == "cm" else None)
313
349
 
@@ -324,10 +360,7 @@ async def insert_admin_queue(
324
360
  "z_cost": None,
325
361
  "schedule_id": None,
326
362
  }
327
- if last_pay_uid:
328
- pos = await db.get_shadow_position_after(last_pay_uid)
329
- else:
330
- pos = len(shadow.items)
363
+ pos = _shadow_index_after_uid(shadow, target_uid)
331
364
  await shadow.insert_at(item, pos)
332
365
 
333
366
  # Queue history (zero cost, admin tier)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.5.1"
3
+ version = "0.5.2"
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"