kryten-webqueue 0.4.5__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 (63) hide show
  1. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/CHANGELOG.md +23 -0
  2. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/deploy/nginx-queue.conf +1 -1
  4. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/api_gate/client.py +3 -0
  5. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/catalog/sync.py +7 -1
  6. kryten_webqueue-0.5.0/kryten_webqueue/queue/ordering.py +364 -0
  7. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/admin_queue.py +38 -1
  8. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/queue.py +4 -2
  9. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/catalog/browse.html +22 -0
  10. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/pyproject.toml +1 -1
  11. kryten_webqueue-0.4.5/kryten_webqueue/queue/ordering.py +0 -212
  12. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/.github/workflows/python-publish.yml +0 -0
  13. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/.github/workflows/release.yml +0 -0
  14. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/.gitignore +0 -0
  15. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/README.md +0 -0
  16. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/config.example.json +0 -0
  17. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/deploy/kryten-webqueue.service +0 -0
  18. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  19. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/docs/IMPL_API_GATE.md +0 -0
  20. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/docs/IMPL_ECONOMY.md +0 -0
  21. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  22. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/docs/IMPL_ROBOT.md +0 -0
  23. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/docs/PRE_PLAN_GAPS.md +0 -0
  24. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/docs/PRODUCT_PLAN.md +0 -0
  25. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/__init__.py +0 -0
  26. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/__main__.py +0 -0
  27. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  28. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/app.py +0 -0
  29. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/auth/__init__.py +0 -0
  30. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/auth/otp.py +0 -0
  31. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  32. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/auth/session.py +0 -0
  33. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/catalog/__init__.py +0 -0
  34. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/catalog/db.py +0 -0
  35. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/catalog/images.py +0 -0
  36. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/config.py +0 -0
  37. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/playlists/__init__.py +0 -0
  38. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/playlists/fire.py +0 -0
  39. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/playlists/importer.py +0 -0
  40. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  41. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/queue/__init__.py +0 -0
  42. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/queue/poller.py +0 -0
  43. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/queue/shadow.py +0 -0
  44. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/__init__.py +0 -0
  45. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  46. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  47. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/auth.py +0 -0
  48. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/catalog.py +0 -0
  49. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/pages.py +0 -0
  50. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/user.py +0 -0
  51. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/static/css/main.css +0 -0
  52. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/static/js/main.js +0 -0
  53. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/index.html +0 -0
  54. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  55. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  56. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  57. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/auth/login.html +0 -0
  58. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/base.html +0 -0
  59. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/queue/index.html +0 -0
  60. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  61. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/ws/__init__.py +0 -0
  62. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/ws/handler.py +0 -0
  63. {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/ws/manager.py +0 -0
@@ -5,6 +5,29 @@ 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
+
23
+ ## [0.4.6] - 2026-06-04
24
+
25
+ ### Fixed
26
+
27
+ - **Wrong media ID sent to CyTube** — `/queue/add` and `/queue/playnext` were passing `friendly_token` (the MediaCMS slug, e.g. `"my-movie-2024"`) as the `id` field for CyTube custom media type `"cm"`. CyTube requires the manifest URL as the ID; the slug was silently rejected, the `queue` confirmation event never fired, the Robot waited 8 seconds, and kryten-py's matching 8-second timeout fired first giving a 504. Fixed by passing `item["manifest_url"]` as `media_id` to CyTube and threading `friendly_token` separately through `insert_pay_queue` / `insert_pay_playnext` for spend/history records
28
+
29
+ [0.4.6]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.4.6
30
+
8
31
  ## [0.4.5] - 2026-06-04
9
32
 
10
33
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.4.5
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}"
@@ -0,0 +1,364 @@
1
+ import asyncio
2
+ import uuid
3
+ import logging
4
+
5
+ import httpx
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Module-level lock for queue ordering
10
+ _queue_lock = asyncio.Lock()
11
+
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
+
53
+ async def insert_pay_queue(
54
+ *,
55
+ api_gate,
56
+ shadow,
57
+ db,
58
+ username: str,
59
+ media_type: str,
60
+ media_id: str,
61
+ friendly_token: str | None = None,
62
+ title: str,
63
+ duration_sec: int,
64
+ tier: str,
65
+ z_cost: int,
66
+ ) -> dict:
67
+ """Insert a paid item at the end of the pay-queue section (FIFO)."""
68
+ async with _queue_lock:
69
+ request_id = str(uuid.uuid4())
70
+
71
+ # Spend currency (api-gate _unwrap strips the success envelope;
72
+ # raise_for_status propagates failures as httpx.HTTPStatusError)
73
+ try:
74
+ await api_gate.queue_spend(
75
+ username=username,
76
+ duration_sec=duration_sec,
77
+ tier=tier,
78
+ request_id=request_id,
79
+ )
80
+ except httpx.HTTPStatusError as exc:
81
+ return {"success": False, "error": f"Spend failed: {exc.response.status_code}"}
82
+
83
+ # Find position: after last pay item, or prepend if none
84
+ last_pay_uid = await db.get_last_pay_uid()
85
+ position = "end" if not last_pay_uid else str(last_pay_uid)
86
+
87
+ # Add to CyTube playlist
88
+ try:
89
+ add_result = await api_gate.playlist_add(
90
+ media_type=media_type,
91
+ media_id=media_id,
92
+ position=position,
93
+ )
94
+ except httpx.HTTPStatusError as exc:
95
+ try:
96
+ await api_gate.queue_refund(username=username, request_id=request_id, reason="playlist_add_failed")
97
+ except Exception:
98
+ pass
99
+ return {"success": False, "error": _add_failure_reason(None, exc)}
100
+ if not add_result.get("success"):
101
+ # Refund on failure
102
+ try:
103
+ await api_gate.queue_refund(username=username, request_id=request_id, reason="add_failed")
104
+ except Exception:
105
+ pass
106
+ return {"success": False, "error": _add_failure_reason(add_result, None)}
107
+
108
+ uid = add_result["uid"]
109
+
110
+ # Move after last pay UID if needed; refund + remove if positioning fails
111
+ if 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"}
124
+
125
+ # Record spend
126
+ _ft = friendly_token if friendly_token is not None else (media_id if media_type == "cm" else None)
127
+ await db.save_spend_request(
128
+ request_id, username=username, uid=uid,
129
+ friendly_token=_ft,
130
+ tier=tier, z_cost=z_cost,
131
+ )
132
+
133
+ # Update local shadow
134
+ item = {
135
+ "uid": uid,
136
+ "title": title,
137
+ "media_type": media_type,
138
+ "media_id": media_id,
139
+ "duration_sec": duration_sec,
140
+ "is_pay": True,
141
+ "paid_by": username,
142
+ "tier": tier,
143
+ "z_cost": z_cost,
144
+ "schedule_id": None,
145
+ }
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)
151
+ await shadow.insert_at(item, pos)
152
+
153
+ # Queue history
154
+ await db.add_queue_history(
155
+ username=username, friendly_token=_ft,
156
+ title=title, tier=tier, z_cost=z_cost,
157
+ )
158
+
159
+ # Announce placement to the channel
160
+ await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
161
+
162
+ return {"success": True, "uid": uid, "request_id": request_id}
163
+
164
+
165
+ async def insert_pay_playnext(
166
+ *,
167
+ api_gate,
168
+ shadow,
169
+ db,
170
+ username: str,
171
+ media_type: str,
172
+ media_id: str,
173
+ friendly_token: str | None = None,
174
+ title: str,
175
+ duration_sec: int,
176
+ tier: str,
177
+ z_cost: int,
178
+ ) -> dict:
179
+ """Insert a paid item at position 0 (play next)."""
180
+ async with _queue_lock:
181
+ request_id = str(uuid.uuid4())
182
+
183
+ # Spend currency (api-gate _unwrap strips the success envelope;
184
+ # raise_for_status propagates failures as httpx.HTTPStatusError)
185
+ try:
186
+ await api_gate.queue_spend(
187
+ username=username,
188
+ duration_sec=duration_sec,
189
+ tier=tier,
190
+ request_id=request_id,
191
+ )
192
+ except httpx.HTTPStatusError as exc:
193
+ return {"success": False, "error": f"Spend failed: {exc.response.status_code}"}
194
+
195
+ # Add to CyTube playlist at prepend position
196
+ try:
197
+ add_result = await api_gate.playlist_add(
198
+ media_type=media_type,
199
+ media_id=media_id,
200
+ position="end",
201
+ )
202
+ except httpx.HTTPStatusError as exc:
203
+ try:
204
+ await api_gate.queue_refund(username=username, request_id=request_id, reason="playlist_add_failed")
205
+ except Exception:
206
+ pass
207
+ return {"success": False, "error": _add_failure_reason(None, exc)}
208
+ if not add_result.get("success"):
209
+ try:
210
+ await api_gate.queue_refund(username=username, request_id=request_id, reason="add_failed")
211
+ except Exception:
212
+ pass
213
+ return {"success": False, "error": _add_failure_reason(add_result, None)}
214
+
215
+ uid = add_result["uid"]
216
+
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"}
230
+
231
+ # Record spend
232
+ _ft = friendly_token if friendly_token is not None else (media_id if media_type == "cm" else None)
233
+ await db.save_spend_request(
234
+ request_id, username=username, uid=uid,
235
+ friendly_token=_ft,
236
+ tier=tier, z_cost=z_cost,
237
+ )
238
+
239
+ # Update local shadow at position 0
240
+ item = {
241
+ "uid": uid,
242
+ "title": title,
243
+ "media_type": media_type,
244
+ "media_id": media_id,
245
+ "duration_sec": duration_sec,
246
+ "is_pay": True,
247
+ "paid_by": username,
248
+ "tier": tier,
249
+ "z_cost": z_cost,
250
+ "schedule_id": None,
251
+ }
252
+ await shadow.insert_at(item, 0)
253
+
254
+ await db.add_queue_history(
255
+ username=username, friendly_token=_ft,
256
+ title=title, tier=tier, z_cost=z_cost,
257
+ )
258
+
259
+ # Announce placement to the channel
260
+ await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
261
+
262
+ return {"success": True, "uid": uid, "request_id": request_id}
263
+
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
+
345
+ async def refund_item(*, api_gate, db, uid: int, reason: str) -> bool:
346
+ """Refund a paid queue item."""
347
+ request_id = await db.get_request_id_for_uid(uid)
348
+ if not request_id:
349
+ return False
350
+
351
+ # Look up the spend to find username
352
+ row = await db._fetch_one("SELECT username FROM spend_requests WHERE request_id=?", [request_id])
353
+ if not row:
354
+ return False
355
+
356
+ result = await api_gate.queue_refund(
357
+ username=row["username"],
358
+ request_id=request_id,
359
+ reason=reason,
360
+ )
361
+ if result.get("success"):
362
+ await db.mark_spend_refunded(request_id)
363
+ return True
364
+ return False
@@ -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)."""
@@ -56,7 +56,8 @@ async def add_to_queue(request: Request, user: dict = Depends(get_current_user))
56
56
  db=db,
57
57
  username=user["username"],
58
58
  media_type="cm",
59
- media_id=friendly_token,
59
+ media_id=item["manifest_url"],
60
+ friendly_token=friendly_token,
60
61
  title=item["title"],
61
62
  duration_sec=item["duration_sec"],
62
63
  tier=tier,
@@ -111,7 +112,8 @@ async def play_next(request: Request, user: dict = Depends(get_current_user)):
111
112
  db=db,
112
113
  username=user["username"],
113
114
  media_type="cm",
114
- media_id=friendly_token,
115
+ media_id=item["manifest_url"],
116
+ friendly_token=friendly_token,
115
117
  title=item["title"],
116
118
  duration_sec=item["duration_sec"],
117
119
  tier=tier,
@@ -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.5"
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"
@@ -1,212 +0,0 @@
1
- import asyncio
2
- import uuid
3
- import logging
4
-
5
- import httpx
6
-
7
- logger = logging.getLogger(__name__)
8
-
9
- # Module-level lock for queue ordering
10
- _queue_lock = asyncio.Lock()
11
-
12
-
13
- async def insert_pay_queue(
14
- *,
15
- api_gate,
16
- shadow,
17
- db,
18
- username: str,
19
- media_type: str,
20
- media_id: str,
21
- title: str,
22
- duration_sec: int,
23
- tier: str,
24
- z_cost: int,
25
- ) -> dict:
26
- """Insert a paid item at the end of the pay-queue section (FIFO)."""
27
- async with _queue_lock:
28
- request_id = str(uuid.uuid4())
29
-
30
- # Spend currency (api-gate _unwrap strips the success envelope;
31
- # raise_for_status propagates failures as httpx.HTTPStatusError)
32
- try:
33
- await api_gate.queue_spend(
34
- username=username,
35
- duration_sec=duration_sec,
36
- tier=tier,
37
- request_id=request_id,
38
- )
39
- except httpx.HTTPStatusError as exc:
40
- return {"success": False, "error": f"Spend failed: {exc.response.status_code}"}
41
-
42
- # Find position: after last pay item, or prepend if none
43
- last_pay_uid = await db.get_last_pay_uid()
44
- position = "end" if not last_pay_uid else str(last_pay_uid)
45
-
46
- # Add to CyTube playlist
47
- try:
48
- add_result = await api_gate.playlist_add(
49
- media_type=media_type,
50
- media_id=media_id,
51
- position=position,
52
- )
53
- except httpx.HTTPStatusError:
54
- try:
55
- await api_gate.queue_refund(username=username, request_id=request_id, reason="playlist_add_failed")
56
- except Exception:
57
- pass
58
- return {"success": False, "error": "Failed to add to playlist"}
59
- if not add_result.get("success"):
60
- # Refund on failure
61
- try:
62
- await api_gate.queue_refund(username=username, request_id=request_id, reason="add_failed")
63
- except Exception:
64
- pass
65
- return {"success": False, "error": "Failed to add to playlist"}
66
-
67
- uid = add_result["uid"]
68
-
69
- # Move after last pay UID if needed
70
- if last_pay_uid:
71
- await api_gate.playlist_move(uid, last_pay_uid)
72
-
73
- # Record spend
74
- await db.save_spend_request(
75
- request_id, username=username, uid=uid,
76
- friendly_token=media_id if media_type == "cm" else None,
77
- tier=tier, z_cost=z_cost,
78
- )
79
-
80
- # Update local shadow
81
- item = {
82
- "uid": uid,
83
- "title": title,
84
- "media_type": media_type,
85
- "media_id": media_id,
86
- "duration_sec": duration_sec,
87
- "is_pay": True,
88
- "paid_by": username,
89
- "tier": tier,
90
- "z_cost": z_cost,
91
- "schedule_id": None,
92
- }
93
- # Position after last pay
94
- if last_pay_uid:
95
- pos = await db.get_shadow_position_after(last_pay_uid)
96
- else:
97
- pos = len(shadow.items)
98
- await shadow.insert_at(item, pos)
99
-
100
- # Queue history
101
- await db.add_queue_history(
102
- username=username, friendly_token=media_id if media_type == "cm" else None,
103
- title=title, tier=tier, z_cost=z_cost,
104
- )
105
-
106
- return {"success": True, "uid": uid, "request_id": request_id}
107
-
108
-
109
- async def insert_pay_playnext(
110
- *,
111
- api_gate,
112
- shadow,
113
- db,
114
- username: str,
115
- media_type: str,
116
- media_id: str,
117
- title: str,
118
- duration_sec: int,
119
- tier: str,
120
- z_cost: int,
121
- ) -> dict:
122
- """Insert a paid item at position 0 (play next)."""
123
- async with _queue_lock:
124
- request_id = str(uuid.uuid4())
125
-
126
- # Spend currency (api-gate _unwrap strips the success envelope;
127
- # raise_for_status propagates failures as httpx.HTTPStatusError)
128
- try:
129
- await api_gate.queue_spend(
130
- username=username,
131
- duration_sec=duration_sec,
132
- tier=tier,
133
- request_id=request_id,
134
- )
135
- except httpx.HTTPStatusError as exc:
136
- return {"success": False, "error": f"Spend failed: {exc.response.status_code}"}
137
-
138
- # Add to CyTube playlist at prepend position
139
- try:
140
- add_result = await api_gate.playlist_add(
141
- media_type=media_type,
142
- media_id=media_id,
143
- position="end",
144
- )
145
- except httpx.HTTPStatusError:
146
- try:
147
- await api_gate.queue_refund(username=username, request_id=request_id, reason="playlist_add_failed")
148
- except Exception:
149
- pass
150
- return {"success": False, "error": "Failed to add to playlist"}
151
- if not add_result.get("success"):
152
- try:
153
- await api_gate.queue_refund(username=username, request_id=request_id, reason="add_failed")
154
- except Exception:
155
- pass
156
- return {"success": False, "error": "Failed to add to playlist"}
157
-
158
- uid = add_result["uid"]
159
-
160
- # Move to front
161
- await api_gate.playlist_move(uid, "prepend")
162
-
163
- # Record spend
164
- await db.save_spend_request(
165
- request_id, username=username, uid=uid,
166
- friendly_token=media_id if media_type == "cm" else None,
167
- tier=tier, z_cost=z_cost,
168
- )
169
-
170
- # Update local shadow at position 0
171
- item = {
172
- "uid": uid,
173
- "title": title,
174
- "media_type": media_type,
175
- "media_id": media_id,
176
- "duration_sec": duration_sec,
177
- "is_pay": True,
178
- "paid_by": username,
179
- "tier": tier,
180
- "z_cost": z_cost,
181
- "schedule_id": None,
182
- }
183
- await shadow.insert_at(item, 0)
184
-
185
- await db.add_queue_history(
186
- username=username, friendly_token=media_id if media_type == "cm" else None,
187
- title=title, tier=tier, z_cost=z_cost,
188
- )
189
-
190
- return {"success": True, "uid": uid, "request_id": request_id}
191
-
192
-
193
- async def refund_item(*, api_gate, db, uid: int, reason: str) -> bool:
194
- """Refund a paid queue item."""
195
- request_id = await db.get_request_id_for_uid(uid)
196
- if not request_id:
197
- return False
198
-
199
- # Look up the spend to find username
200
- row = await db._fetch_one("SELECT username FROM spend_requests WHERE request_id=?", [request_id])
201
- if not row:
202
- return False
203
-
204
- result = await api_gate.queue_refund(
205
- username=row["username"],
206
- request_id=request_id,
207
- reason=reason,
208
- )
209
- if result.get("success"):
210
- await db.mark_spend_refunded(request_id)
211
- return True
212
- return False