kryten-webqueue 0.4.6__tar.gz → 0.5.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 (62) hide show
  1. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/CHANGELOG.md +15 -0
  2. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/deploy/nginx-queue.conf +1 -1
  4. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/api_gate/client.py +3 -0
  5. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/catalog/sync.py +7 -1
  6. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/queue/ordering.py +158 -10
  7. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/admin_queue.py +38 -1
  8. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/catalog/browse.html +22 -0
  9. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/pyproject.toml +1 -1
  10. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/.github/workflows/python-publish.yml +0 -0
  11. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/.github/workflows/release.yml +0 -0
  12. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/.gitignore +0 -0
  13. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/README.md +0 -0
  14. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/config.example.json +0 -0
  15. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/deploy/kryten-webqueue.service +0 -0
  16. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  17. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/docs/IMPL_API_GATE.md +0 -0
  18. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/docs/IMPL_ECONOMY.md +0 -0
  19. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  20. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/docs/IMPL_ROBOT.md +0 -0
  21. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/docs/PRE_PLAN_GAPS.md +0 -0
  22. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/docs/PRODUCT_PLAN.md +0 -0
  23. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/__init__.py +0 -0
  24. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/__main__.py +0 -0
  25. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  26. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/app.py +0 -0
  27. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/auth/__init__.py +0 -0
  28. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/auth/otp.py +0 -0
  29. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  30. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/auth/session.py +0 -0
  31. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/catalog/__init__.py +0 -0
  32. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/catalog/db.py +0 -0
  33. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/catalog/images.py +0 -0
  34. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/config.py +0 -0
  35. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/playlists/__init__.py +0 -0
  36. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/playlists/fire.py +0 -0
  37. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/playlists/importer.py +0 -0
  38. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  39. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/queue/__init__.py +0 -0
  40. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/queue/poller.py +0 -0
  41. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/queue/shadow.py +0 -0
  42. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/__init__.py +0 -0
  43. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  44. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  45. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/auth.py +0 -0
  46. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/catalog.py +0 -0
  47. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/pages.py +0 -0
  48. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/queue.py +0 -0
  49. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/user.py +0 -0
  50. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/static/css/main.css +0 -0
  51. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/static/js/main.js +0 -0
  52. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/index.html +0 -0
  53. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  54. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  55. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  56. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/auth/login.html +0 -0
  57. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/base.html +0 -0
  58. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/queue/index.html +0 -0
  59. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  60. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/ws/__init__.py +0 -0
  61. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/ws/handler.py +0 -0
  62. {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.0}/kryten_webqueue/ws/manager.py +0 -0
@@ -5,6 +5,21 @@ 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.0] - 2026-06-04
9
+
10
+ ### Added
11
+
12
+ - **Queue as Admin** — Admin users (rank ≥ 3) now see a "Queue as Admin" button on catalog cards. It queues the item at zero cost in the first available non-pay slot (top of the free section, below any paid items), treating it exactly like a non-paid item and skipping all economy interaction. New route `POST /admin/queue/add` and `insert_admin_queue()` ordering helper
13
+ - **Channel announcement on successful queue** — After an item is successfully queued and positioned, the channel chat receives `"<title> has been queued in position <N> by <user>"`, where position counts from the currently-playing item (position 0), so position 1 is the next item to play. Added `ApiGateClient.send_chat()`
14
+
15
+ ### Fixed
16
+
17
+ - **Wrong manifest URL stored during catalog sync** — `_build_manifest_url()` was producing the MediaCMS watch/detail page URL (`/view?m=TOKEN`, which returns HTTP 302) instead of the real CyTube manifest (`/api/v1/media/cytube/TOKEN.json?format=json`). CyTube rejected the 302 with "Expected HTTP 200 OK, not 302 Found", causing every queue attempt to fail. Existing rows are corrected on the next startup sync
18
+ - **queueFail now surfaced from the command response** — Instead of relying on a timeout, the Robot's `addvideo` command response now carries the CyTube `queueFail` reason. The api-gate returns it as HTTP 422 and webqueue refunds the spend and reports the actual reason. webqueue no longer needs to subscribe to the `kryten.events.cytube.channel-z.queuefail` events channel
19
+ - **Refund when an item cannot be positioned** — If `playlist_move` fails after a successful add, the spend is now refunded and the mis-placed item is removed, rather than leaving a paid item in the wrong slot
20
+
21
+ [0.5.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.5.0
22
+
8
23
  ## [0.4.6] - 2026-06-04
9
24
 
10
25
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.4.6
3
+ Version: 0.5.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
@@ -6,7 +6,7 @@ server {
6
6
  ssl_certificate /etc/letsencrypt/live/queue.dropsugar.co/fullchain.pem;
7
7
  ssl_certificate_key /etc/letsencrypt/live/queue.dropsugar.co/privkey.pem;
8
8
  ssl_protocols TLSv1.2 TLSv1.3;
9
- ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
9
+ ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128it-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
10
10
  ssl_prefer_server_ciphers off;
11
11
  ssl_session_cache shared:SSL:10m;
12
12
  ssl_session_timeout 1d;
@@ -73,6 +73,9 @@ class ApiGateClient:
73
73
 
74
74
  # --- Chat ---
75
75
 
76
+ async def send_chat(self, message: str) -> dict:
77
+ return await self.post("/chat/send", json={"message": message})
78
+
76
79
  async def send_pm(self, username: str, message: str) -> dict:
77
80
  return await self.post("/chat/pm", json={"username": username, "message": message})
78
81
 
@@ -138,6 +138,12 @@ class CatalogSync:
138
138
  await asyncio.sleep(0.25)
139
139
 
140
140
  def _build_manifest_url(self, media: dict) -> str:
141
- # Use the MediaCMS watch page URL (e.g. https://www.dropsugar.co/view?m=TOKEN)
141
+ # CyTube custom media ("cm") requires the manifest JSON URL, NOT the
142
+ # human watch page (/view?m=TOKEN). MediaCMS exposes a CyTube manifest at
143
+ # /api/v1/media/cytube/<token>.json?format=json
144
+ token = media.get("friendly_token")
145
+ if token:
146
+ return f"{self._url}/api/v1/media/cytube/{token}.json?format=json"
147
+ # Fallback: derive token from the watch URL if friendly_token is missing
142
148
  url = media.get("url", "")
143
149
  return url if url.startswith("http") else f"{self._url}{url}"
@@ -10,6 +10,46 @@ logger = logging.getLogger(__name__)
10
10
  _queue_lock = asyncio.Lock()
11
11
 
12
12
 
13
+ def _add_failure_reason(add_result: dict | None, exc: httpx.HTTPStatusError | None) -> str:
14
+ """Extract a human-readable reason from a failed playlist add."""
15
+ if exc is not None:
16
+ try:
17
+ detail = exc.response.json().get("detail")
18
+ if detail:
19
+ return str(detail)
20
+ except Exception:
21
+ pass
22
+ return f"playlist add failed ({exc.response.status_code})"
23
+ if add_result is not None:
24
+ return str(add_result.get("error", "Failed to add to playlist"))
25
+ return "Failed to add to playlist"
26
+
27
+
28
+ def _announcement_position(shadow, uid: int) -> int | None:
29
+ """Position of the item for chat announcement.
30
+
31
+ Counting starts at the currently-playing item (position 0), so the next
32
+ item to play is position 1. The shadow mirrors the full CyTube playlist
33
+ (including the active item at index 0), so the item's shadow index is the
34
+ announcement number.
35
+ """
36
+ for it in shadow.items:
37
+ if it.get("uid") == uid:
38
+ return it.get("position")
39
+ return None
40
+
41
+
42
+ async def _announce_queued(api_gate, shadow, *, uid: int, title: str, username: str) -> None:
43
+ """Announce a successful queue placement to the channel chat."""
44
+ pos = _announcement_position(shadow, uid)
45
+ if pos is None:
46
+ return
47
+ try:
48
+ await api_gate.send_chat(f"{title} has been queued in position {pos} by {username}")
49
+ except Exception:
50
+ logger.warning("Failed to send queue announcement", exc_info=True)
51
+
52
+
13
53
  async def insert_pay_queue(
14
54
  *,
15
55
  api_gate,
@@ -51,25 +91,36 @@ async def insert_pay_queue(
51
91
  media_id=media_id,
52
92
  position=position,
53
93
  )
54
- except httpx.HTTPStatusError:
94
+ except httpx.HTTPStatusError as exc:
55
95
  try:
56
96
  await api_gate.queue_refund(username=username, request_id=request_id, reason="playlist_add_failed")
57
97
  except Exception:
58
98
  pass
59
- return {"success": False, "error": "Failed to add to playlist"}
99
+ return {"success": False, "error": _add_failure_reason(None, exc)}
60
100
  if not add_result.get("success"):
61
101
  # Refund on failure
62
102
  try:
63
103
  await api_gate.queue_refund(username=username, request_id=request_id, reason="add_failed")
64
104
  except Exception:
65
105
  pass
66
- return {"success": False, "error": "Failed to add to playlist"}
106
+ return {"success": False, "error": _add_failure_reason(add_result, None)}
67
107
 
68
108
  uid = add_result["uid"]
69
109
 
70
- # Move after last pay UID if needed
110
+ # Move after last pay UID if needed; refund + remove if positioning fails
71
111
  if last_pay_uid:
72
- await api_gate.playlist_move(uid, last_pay_uid)
112
+ 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"}
73
124
 
74
125
  # Record spend
75
126
  _ft = friendly_token if friendly_token is not None else (media_id if media_type == "cm" else None)
@@ -105,6 +156,9 @@ async def insert_pay_queue(
105
156
  title=title, tier=tier, z_cost=z_cost,
106
157
  )
107
158
 
159
+ # Announce placement to the channel
160
+ await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
161
+
108
162
  return {"success": True, "uid": uid, "request_id": request_id}
109
163
 
110
164
 
@@ -145,23 +199,34 @@ async def insert_pay_playnext(
145
199
  media_id=media_id,
146
200
  position="end",
147
201
  )
148
- except httpx.HTTPStatusError:
202
+ except httpx.HTTPStatusError as exc:
149
203
  try:
150
204
  await api_gate.queue_refund(username=username, request_id=request_id, reason="playlist_add_failed")
151
205
  except Exception:
152
206
  pass
153
- return {"success": False, "error": "Failed to add to playlist"}
207
+ return {"success": False, "error": _add_failure_reason(None, exc)}
154
208
  if not add_result.get("success"):
155
209
  try:
156
210
  await api_gate.queue_refund(username=username, request_id=request_id, reason="add_failed")
157
211
  except Exception:
158
212
  pass
159
- return {"success": False, "error": "Failed to add to playlist"}
213
+ return {"success": False, "error": _add_failure_reason(add_result, None)}
160
214
 
161
215
  uid = add_result["uid"]
162
216
 
163
- # Move to front
164
- await api_gate.playlist_move(uid, "prepend")
217
+ # Move to front; refund + remove if positioning fails
218
+ try:
219
+ await api_gate.playlist_move(uid, "prepend")
220
+ except httpx.HTTPStatusError:
221
+ try:
222
+ await api_gate.queue_refund(username=username, request_id=request_id, reason="move_failed")
223
+ except Exception:
224
+ pass
225
+ try:
226
+ await api_gate.playlist_delete(uid)
227
+ except Exception:
228
+ pass
229
+ return {"success": False, "error": "Failed to position item in queue"}
165
230
 
166
231
  # Record spend
167
232
  _ft = friendly_token if friendly_token is not None else (media_id if media_type == "cm" else None)
@@ -191,9 +256,92 @@ async def insert_pay_playnext(
191
256
  title=title, tier=tier, z_cost=z_cost,
192
257
  )
193
258
 
259
+ # Announce placement to the channel
260
+ await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
261
+
194
262
  return {"success": True, "uid": uid, "request_id": request_id}
195
263
 
196
264
 
265
+ async def insert_admin_queue(
266
+ *,
267
+ api_gate,
268
+ shadow,
269
+ db,
270
+ username: str,
271
+ media_type: str,
272
+ media_id: str,
273
+ friendly_token: str | None = None,
274
+ title: str,
275
+ duration_sec: int,
276
+ ) -> dict:
277
+ """Insert a zero-cost admin item in the first available non-pay slot.
278
+
279
+ Treated exactly like a non-paid item (no economy interaction). It is placed
280
+ immediately after the last paid item, i.e. at the top of the free section.
281
+ """
282
+ async with _queue_lock:
283
+ # First available non-pay slot is right after the last pay item.
284
+ last_pay_uid = await db.get_last_pay_uid()
285
+ position = "end" if not last_pay_uid else str(last_pay_uid)
286
+
287
+ # Add to CyTube playlist
288
+ try:
289
+ add_result = await api_gate.playlist_add(
290
+ media_type=media_type,
291
+ media_id=media_id,
292
+ position=position,
293
+ )
294
+ except httpx.HTTPStatusError as exc:
295
+ return {"success": False, "error": _add_failure_reason(None, exc)}
296
+ if not add_result.get("success"):
297
+ return {"success": False, "error": _add_failure_reason(add_result, None)}
298
+
299
+ uid = add_result["uid"]
300
+
301
+ # Move to the top of the free section if there are pay items above
302
+ if last_pay_uid:
303
+ 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"}
311
+
312
+ _ft = friendly_token if friendly_token is not None else (media_id if media_type == "cm" else None)
313
+
314
+ # Update local shadow as a non-paid item
315
+ item = {
316
+ "uid": uid,
317
+ "title": title,
318
+ "media_type": media_type,
319
+ "media_id": media_id,
320
+ "duration_sec": duration_sec,
321
+ "is_pay": False,
322
+ "paid_by": None,
323
+ "tier": None,
324
+ "z_cost": None,
325
+ "schedule_id": None,
326
+ }
327
+ if last_pay_uid:
328
+ pos = await db.get_shadow_position_after(last_pay_uid)
329
+ else:
330
+ pos = len(shadow.items)
331
+ await shadow.insert_at(item, pos)
332
+
333
+ # Queue history (zero cost, admin tier)
334
+ await db.add_queue_history(
335
+ username=username, friendly_token=_ft,
336
+ title=title, tier="admin", z_cost=0,
337
+ )
338
+
339
+ # Announce placement to the channel
340
+ await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
341
+
342
+ return {"success": True, "uid": uid}
343
+
344
+
197
345
  async def refund_item(*, api_gate, db, uid: int, reason: str) -> bool:
198
346
  """Refund a paid queue item."""
199
347
  request_id = await db.get_request_id_for_uid(uid)
@@ -1,11 +1,48 @@
1
1
  from fastapi import APIRouter, Request, Depends, HTTPException
2
2
 
3
3
  from ..auth.session import require_admin
4
- from ..queue.ordering import refund_item
4
+ from ..queue.ordering import refund_item, insert_admin_queue
5
5
 
6
6
  router = APIRouter(prefix="/admin/queue", tags=["admin"])
7
7
 
8
8
 
9
+ @router.post("/add")
10
+ async def admin_add(request: Request, user: dict = Depends(require_admin)):
11
+ """Queue an item as admin: zero cost, first available non-pay slot."""
12
+ body = await request.json()
13
+ friendly_token = body.get("friendly_token")
14
+ if not friendly_token:
15
+ raise HTTPException(400, "friendly_token required")
16
+
17
+ db = request.app.state.db
18
+ api_gate = request.app.state.api_gate
19
+ shadow = request.app.state.shadow
20
+
21
+ # Check pre-fire lock
22
+ if await db.is_pre_fire_lock_active():
23
+ raise HTTPException(423, "Queue is locked: scheduled playlist firing soon")
24
+
25
+ item = await db.get_item(friendly_token)
26
+ if not item:
27
+ raise HTTPException(404, "Item not found in catalog")
28
+
29
+ result = await insert_admin_queue(
30
+ api_gate=api_gate,
31
+ shadow=shadow,
32
+ db=db,
33
+ username=user["username"],
34
+ media_type="cm",
35
+ media_id=item["manifest_url"],
36
+ friendly_token=friendly_token,
37
+ title=item["title"],
38
+ duration_sec=item["duration_sec"],
39
+ )
40
+
41
+ if not result["success"]:
42
+ raise HTTPException(400, result.get("error", "Admin queue add failed"))
43
+ return result
44
+
45
+
9
46
  @router.post("/clear")
10
47
  async def clear_queue(request: Request, user: dict = Depends(require_admin)):
11
48
  """Clear the CyTube playlist (refunds all pay items)."""
@@ -48,6 +48,9 @@
48
48
  <div class="card-actions">
49
49
  <button class="btn btn-sm btn-queue" onclick="queueItem('{{ item.friendly_token }}')">Queue</button>
50
50
  <button class="btn btn-sm btn-playnext" onclick="playNext('{{ item.friendly_token }}')">Play Next</button>
51
+ {% if user.rank >= 3 %}
52
+ <button class="btn btn-sm btn-admin" onclick="queueAsAdmin('{{ item.friendly_token }}')">Queue as Admin</button>
53
+ {% endif %}
51
54
  </div>
52
55
  </div>
53
56
  {% endfor %}
@@ -126,5 +129,24 @@ async function playNext(token) {
126
129
  showToast(`Network error: ${e.message}`, 'error');
127
130
  }
128
131
  }
132
+
133
+ async function queueAsAdmin(token) {
134
+ try {
135
+ const resp = await fetch('/admin/queue/add', {
136
+ method: 'POST',
137
+ headers: {'Content-Type': 'application/json'},
138
+ credentials: 'same-origin',
139
+ body: JSON.stringify({friendly_token: token})
140
+ });
141
+ const data = await resp.json();
142
+ if (resp.ok) {
143
+ showToast('Queued as admin!');
144
+ } else {
145
+ showToast(data.detail || `Failed (${resp.status})`, 'error');
146
+ }
147
+ } catch (e) {
148
+ showToast(`Network error: ${e.message}`, 'error');
149
+ }
150
+ }
129
151
  </script>
130
152
  {% endblock %}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.4.6"
3
+ version = "0.5.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"