kryten-webqueue 0.4.6__tar.gz → 0.5.1__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.
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/CHANGELOG.md +23 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/PKG-INFO +1 -1
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/deploy/nginx-queue.conf +1 -1
- kryten_webqueue-0.5.1/kryten_webqueue/__init__.py +6 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/api_gate/client.py +3 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/app.py +3 -2
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/catalog/sync.py +7 -1
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/queue/ordering.py +158 -10
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/routes/admin_queue.py +38 -1
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/templates/catalog/browse.html +22 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/pyproject.toml +1 -1
- kryten_webqueue-0.4.6/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/.gitignore +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/README.md +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/config.example.json +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.4.6/kryten_webqueue → kryten_webqueue-0.5.1/kryten_webqueue/api_gate}/__init__.py +0 -0
- {kryten_webqueue-0.4.6/kryten_webqueue/api_gate → kryten_webqueue-0.5.1/kryten_webqueue/auth}/__init__.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.4.6/kryten_webqueue/auth → kryten_webqueue-0.5.1/kryten_webqueue/catalog}/__init__.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/catalog/db.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.4.6/kryten_webqueue/catalog → kryten_webqueue-0.5.1/kryten_webqueue/playlists}/__init__.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.4.6/kryten_webqueue/playlists → kryten_webqueue-0.5.1/kryten_webqueue/queue}/__init__.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.4.6/kryten_webqueue/queue → kryten_webqueue-0.5.1/kryten_webqueue/routes}/__init__.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.4.6/kryten_webqueue/routes → kryten_webqueue-0.5.1/kryten_webqueue/ws}/__init__.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/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.1] - 2026-06-05
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Version logged at startup** — Service version is now read from package metadata (`importlib.metadata`) and logged when the lifespan starts: `kryten-webqueue v<version> started on <host>:<port>`. The same version is exposed in the FastAPI OpenAPI schema (replacing the stale hard-coded `"0.1.0"`)
|
|
13
|
+
|
|
14
|
+
[0.5.1]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.5.1
|
|
15
|
+
|
|
16
|
+
## [0.5.0] - 2026-06-04
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **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
|
|
21
|
+
- **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()`
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **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
|
|
26
|
+
- **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
|
|
27
|
+
- **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
|
|
28
|
+
|
|
29
|
+
[0.5.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.5.0
|
|
30
|
+
|
|
8
31
|
## [0.4.6] - 2026-06-04
|
|
9
32
|
|
|
10
33
|
### Fixed
|
|
@@ -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-
|
|
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
|
|
|
@@ -7,6 +7,7 @@ from fastapi.staticfiles import StaticFiles
|
|
|
7
7
|
from fastapi.templating import Jinja2Templates
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
+
from . import __version__
|
|
10
11
|
from .config import Config
|
|
11
12
|
from .catalog.db import Database
|
|
12
13
|
from .api_gate.client import ApiGateClient
|
|
@@ -137,7 +138,7 @@ async def lifespan(app: FastAPI):
|
|
|
137
138
|
asyncio.create_task(_immutability_expiry_loop()),
|
|
138
139
|
]
|
|
139
140
|
|
|
140
|
-
logger.info(f"kryten-webqueue started on {config.host}:{config.port}")
|
|
141
|
+
logger.info(f"kryten-webqueue v{__version__} started on {config.host}:{config.port}")
|
|
141
142
|
|
|
142
143
|
yield
|
|
143
144
|
|
|
@@ -156,7 +157,7 @@ async def lifespan(app: FastAPI):
|
|
|
156
157
|
def create_app(config: Config) -> FastAPI:
|
|
157
158
|
app = FastAPI(
|
|
158
159
|
title="kryten-webqueue",
|
|
159
|
-
version=
|
|
160
|
+
version=__version__,
|
|
160
161
|
lifespan=lifespan,
|
|
161
162
|
)
|
|
162
163
|
app.state.config = config
|
|
@@ -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
|
-
#
|
|
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":
|
|
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":
|
|
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
|
-
|
|
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":
|
|
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":
|
|
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
|
-
|
|
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)."""
|
{kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
@@ -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 %}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.4.6/kryten_webqueue → kryten_webqueue-0.5.1/kryten_webqueue/api_gate}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.4.6 → kryten_webqueue-0.5.1}/kryten_webqueue/templates/user/dashboard.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|