kryten-webqueue 0.5.2__tar.gz → 0.6.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.5.2 → kryten_webqueue-0.6.1}/CHANGELOG.md +32 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/PKG-INFO +1 -1
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/app.py +13 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/catalog/db.py +63 -0
- kryten_webqueue-0.6.1/kryten_webqueue/jobs/__init__.py +3 -0
- kryten_webqueue-0.6.1/kryten_webqueue/jobs/manager.py +70 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/queue/ordering.py +82 -10
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/queue/poller.py +6 -2
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/queue/shadow.py +54 -0
- kryten_webqueue-0.6.1/kryten_webqueue/routes/admin_jobs.py +32 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/admin_queue.py +7 -1
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/pages.py +17 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/queue.py +40 -3
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/static/css/main.css +158 -0
- kryten_webqueue-0.6.1/kryten_webqueue/static/js/main.js +234 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/admin/index.html +62 -3
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/base.html +3 -3
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/catalog/browse.html +6 -60
- kryten_webqueue-0.6.1/kryten_webqueue/templates/catalog/item_detail.html +48 -0
- kryten_webqueue-0.6.1/kryten_webqueue/templates/catalog/item_not_found.html +10 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/queue/index.html +15 -1
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/pyproject.toml +1 -1
- kryten_webqueue-0.5.2/kryten_webqueue/static/js/main.js +0 -26
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/.gitignore +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/README.md +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/config.example.json +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.1}/kryten_webqueue/ws/manager.py +0 -0
|
@@ -5,6 +5,38 @@ 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.6.1] - 2026-06-05
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Receipt confirmation modal for Queue & Play Next** — Clicking **Queue** or **Play Next** now opens a receipt-style modal before spending. It calls `/queue/preview` and shows the item title, price, any rank discount, total cost, current balance, and balance after the transaction, then asks the user to confirm. Unavailable purchases (insufficient balance, cooldown, daily limit, blackout) disable the confirm button with an explanatory message.
|
|
13
|
+
- **Enriched `/queue/preview` receipt data** — The preview endpoint now returns the catalog `title`, `base_cost`, `discount_amount`, current `balance`, and `balance_after` in addition to the existing cost/discount fields. `base_cost` comes from the economy service when available and is otherwise derived from the discount percentage.
|
|
14
|
+
- **Shared modal styling** — Added CSS for `.modal-overlay`/`.modal-box`/`.modal-actions` (also styling the existing admin queue modal) plus a dedicated receipt table layout.
|
|
15
|
+
|
|
16
|
+
[0.6.1]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.6.1
|
|
17
|
+
|
|
18
|
+
## [0.6.0] - 2026-06-05
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Item detail page** — Clicking a tile's cover art or title now opens `/catalog/item/{friendly_token}`, a dedicated view showing the cover art, full MediaCMS description, duration, and the same action buttons as the tile (Queue, Play Next, and Queue as Admin for admins). Unknown tokens render a 404 "Item not found" page.
|
|
23
|
+
- **Queue page metadata & reorder handle** — Up-next and now-playing items are now enriched with catalog metadata (cover-art thumbnail, correct title, duration) by matching on `friendly_token` or `manifest_url`. Each queue row gains a drag handle affordance, cover thumbnail, paid-by/tier badges, and a correct ETA. Enrichment is applied to both the `/queue/state` response and the WebSocket broadcast.
|
|
24
|
+
- **Generic background job runner** — New `JobManager` runs registered async jobs as background tasks and records each run (start, end, status, detail) to a new `job_runs` table. The Admin page lists registered jobs with **Run** buttons and shows a recent-run history table styled like the catalog sync log. Catalog Sync is now wired through this framework.
|
|
25
|
+
- **Admin job routes** — `GET /admin/jobs`, `GET /admin/jobs/runs`, and `POST /admin/jobs/{name}/run`.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **Top-bar rebrand to "Channel-Z"** — The navigation brand and page-title suffix now read "Channel-Z" instead of "DropSugar Queue"/"DropSugar".
|
|
30
|
+
- **Footer credit links to GitHub** — "kryten-webqueue" in the footer now links to the project's GitHub repository.
|
|
31
|
+
- **All admin time displays are human-readable and timezone-aware** — Sync-log and job-run timestamps are rendered in the browser's local timezone via shared `formatLocalDateTime`/`formatLocalTime` helpers.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **Empty now-playing UID is handled safely** — When the robot KV is uninitialised and the now-playing UID is unavailable, Queue and Play Next purchases are cancelled and refunded instead of landing in an undefined position.
|
|
36
|
+
- **Admin "Queue as Admin" position prompt** — Admins are now prompted to resolve the inserted item's position: *Play next & refund pending* (refunds money and removes pending paid items from the queue), *Play after all purchased items*, or *Cancel*.
|
|
37
|
+
|
|
38
|
+
[0.6.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.6.0
|
|
39
|
+
|
|
8
40
|
## [0.5.2] - 2026-06-05
|
|
9
41
|
|
|
10
42
|
### Fixed
|
|
@@ -12,6 +12,7 @@ from .config import Config
|
|
|
12
12
|
from .catalog.db import Database
|
|
13
13
|
from .api_gate.client import ApiGateClient
|
|
14
14
|
from .catalog.sync import CatalogSync
|
|
15
|
+
from .jobs import JobManager
|
|
15
16
|
from .catalog.images import CoverArtResolver
|
|
16
17
|
from .queue.shadow import QueueShadow
|
|
17
18
|
from .queue.poller import StatePoller
|
|
@@ -26,6 +27,7 @@ from .routes.user import router as user_router
|
|
|
26
27
|
from .routes.admin_playlists import router as admin_playlists_router
|
|
27
28
|
from .routes.admin_schedules import router as admin_schedules_router
|
|
28
29
|
from .routes.admin_queue import router as admin_queue_router
|
|
30
|
+
from .routes.admin_jobs import router as admin_jobs_router
|
|
29
31
|
from .routes.pages import router as pages_router
|
|
30
32
|
from .ws.handler import router as ws_router
|
|
31
33
|
|
|
@@ -64,6 +66,15 @@ async def lifespan(app: FastAPI):
|
|
|
64
66
|
)
|
|
65
67
|
app.state.catalog_sync = catalog_sync
|
|
66
68
|
|
|
69
|
+
# Generic background job runner (records run history to job_runs)
|
|
70
|
+
job_manager = JobManager(db)
|
|
71
|
+
job_manager.register(
|
|
72
|
+
"catalog_sync",
|
|
73
|
+
catalog_sync.sync,
|
|
74
|
+
label="Catalog Sync",
|
|
75
|
+
)
|
|
76
|
+
app.state.job_manager = job_manager
|
|
77
|
+
|
|
67
78
|
# WebSocket manager
|
|
68
79
|
ws_manager = WebSocketManager()
|
|
69
80
|
app.state.ws_manager = ws_manager
|
|
@@ -78,6 +89,7 @@ async def lifespan(app: FastAPI):
|
|
|
78
89
|
api_gate=api_gate,
|
|
79
90
|
shadow=shadow,
|
|
80
91
|
ws_manager=ws_manager,
|
|
92
|
+
db=db,
|
|
81
93
|
interval=config.state_poll_interval_sec,
|
|
82
94
|
)
|
|
83
95
|
await poller.start()
|
|
@@ -171,6 +183,7 @@ def create_app(config: Config) -> FastAPI:
|
|
|
171
183
|
app.include_router(admin_playlists_router)
|
|
172
184
|
app.include_router(admin_schedules_router)
|
|
173
185
|
app.include_router(admin_queue_router)
|
|
186
|
+
app.include_router(admin_jobs_router)
|
|
174
187
|
app.include_router(ws_router)
|
|
175
188
|
|
|
176
189
|
# Health check
|
|
@@ -169,6 +169,19 @@ MIGRATIONS = [
|
|
|
169
169
|
"""
|
|
170
170
|
UPDATE catalog SET cover_art_path = NULL, cover_art_source = NULL;
|
|
171
171
|
""",
|
|
172
|
+
# v4: Generic background job run history
|
|
173
|
+
"""
|
|
174
|
+
CREATE TABLE IF NOT EXISTS job_runs (
|
|
175
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
176
|
+
job_name TEXT NOT NULL,
|
|
177
|
+
started_at TIMESTAMP NOT NULL,
|
|
178
|
+
ended_at TIMESTAMP,
|
|
179
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
180
|
+
detail TEXT,
|
|
181
|
+
triggered_by TEXT
|
|
182
|
+
);
|
|
183
|
+
CREATE INDEX IF NOT EXISTS idx_job_runs_name ON job_runs(job_name, started_at);
|
|
184
|
+
""",
|
|
172
185
|
]
|
|
173
186
|
|
|
174
187
|
|
|
@@ -316,6 +329,29 @@ class Database:
|
|
|
316
329
|
async def get_item_admin(self, friendly_token: str) -> dict | None:
|
|
317
330
|
return await self._fetch_one("SELECT * FROM catalog WHERE friendly_token = ?", [friendly_token])
|
|
318
331
|
|
|
332
|
+
async def get_catalog_brief(self, tokens: list[str], manifest_urls: list[str]) -> dict[str, dict]:
|
|
333
|
+
"""Return a lookup of catalog metadata keyed by BOTH friendly_token and
|
|
334
|
+
manifest_url, for enriching queue-shadow items that may only carry one.
|
|
335
|
+
"""
|
|
336
|
+
keys = [k for k in ({*tokens} | {*manifest_urls}) if k]
|
|
337
|
+
if not keys:
|
|
338
|
+
return {}
|
|
339
|
+
placeholders = ",".join("?" * len(keys))
|
|
340
|
+
rows = await self._fetch_all(
|
|
341
|
+
"SELECT friendly_token, manifest_url, title, duration_sec, "
|
|
342
|
+
"cover_art_path, thumbnail_url FROM catalog "
|
|
343
|
+
f"WHERE friendly_token IN ({placeholders}) OR manifest_url IN ({placeholders})",
|
|
344
|
+
keys + keys,
|
|
345
|
+
)
|
|
346
|
+
lookup: dict[str, dict] = {}
|
|
347
|
+
for row in rows:
|
|
348
|
+
data = dict(row)
|
|
349
|
+
if data.get("friendly_token"):
|
|
350
|
+
lookup[data["friendly_token"]] = data
|
|
351
|
+
if data.get("manifest_url"):
|
|
352
|
+
lookup[data["manifest_url"]] = data
|
|
353
|
+
return lookup
|
|
354
|
+
|
|
319
355
|
async def is_restricted(self, friendly_token: str) -> bool:
|
|
320
356
|
sql = """
|
|
321
357
|
SELECT 1 FROM saved_playlist_items spi
|
|
@@ -393,6 +429,33 @@ class Database:
|
|
|
393
429
|
"SELECT * FROM sync_log ORDER BY id DESC LIMIT ?", [limit]
|
|
394
430
|
)
|
|
395
431
|
|
|
432
|
+
# --- Generic job runs ---
|
|
433
|
+
|
|
434
|
+
async def start_job_run(self, job_name: str, triggered_by: str | None = None) -> int:
|
|
435
|
+
cursor = await self._db.execute(
|
|
436
|
+
"INSERT INTO job_runs (job_name, started_at, status, triggered_by) "
|
|
437
|
+
"VALUES (?, ?, 'running', ?)",
|
|
438
|
+
[job_name, datetime.now(UTC).isoformat(), triggered_by],
|
|
439
|
+
)
|
|
440
|
+
await self._db.commit()
|
|
441
|
+
return cursor.lastrowid
|
|
442
|
+
|
|
443
|
+
async def finish_job_run(self, run_id: int, status: str, detail: str | None = None):
|
|
444
|
+
await self._execute(
|
|
445
|
+
"UPDATE job_runs SET ended_at=?, status=?, detail=? WHERE id=?",
|
|
446
|
+
[datetime.now(UTC).isoformat(), status, detail, run_id],
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
async def get_job_runs(self, job_name: str | None = None, limit: int = 10) -> list[dict]:
|
|
450
|
+
if job_name:
|
|
451
|
+
return await self._fetch_all(
|
|
452
|
+
"SELECT * FROM job_runs WHERE job_name=? ORDER BY id DESC LIMIT ?",
|
|
453
|
+
[job_name, limit],
|
|
454
|
+
)
|
|
455
|
+
return await self._fetch_all(
|
|
456
|
+
"SELECT * FROM job_runs ORDER BY id DESC LIMIT ?", [limit]
|
|
457
|
+
)
|
|
458
|
+
|
|
396
459
|
# --- OTP ---
|
|
397
460
|
|
|
398
461
|
async def store_otp(self, username: str, code: str, expires_at: str):
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Generic background job runner with run-history tracking.
|
|
2
|
+
|
|
3
|
+
Registers named async job functions and runs them as background tasks,
|
|
4
|
+
recording each run (start, end, status, detail) to the ``job_runs`` table
|
|
5
|
+
so the admin UI can display recent history — the same pattern used for
|
|
6
|
+
catalog sync.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Awaitable, Callable
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# A job is an async callable returning an optional dict of result detail.
|
|
17
|
+
JobFunc = Callable[[], Awaitable[dict | None]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JobManager:
|
|
21
|
+
def __init__(self, db):
|
|
22
|
+
self._db = db
|
|
23
|
+
self._jobs: dict[str, dict] = {}
|
|
24
|
+
self._running: dict[str, asyncio.Task] = {}
|
|
25
|
+
|
|
26
|
+
def register(self, name: str, func: JobFunc, *, label: str | None = None):
|
|
27
|
+
"""Register a named job."""
|
|
28
|
+
self._jobs[name] = {"func": func, "label": label or name}
|
|
29
|
+
|
|
30
|
+
def list_jobs(self) -> list[dict]:
|
|
31
|
+
return [
|
|
32
|
+
{"name": name, "label": meta["label"], "running": name in self._running}
|
|
33
|
+
for name, meta in self._jobs.items()
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
def is_running(self, name: str) -> bool:
|
|
37
|
+
return name in self._running
|
|
38
|
+
|
|
39
|
+
async def run(self, name: str, *, triggered_by: str | None = None) -> dict:
|
|
40
|
+
"""Start a job in the background. Returns immediately.
|
|
41
|
+
|
|
42
|
+
Raises KeyError if the job is unknown; returns ``already_running``
|
|
43
|
+
status if a run is in progress.
|
|
44
|
+
"""
|
|
45
|
+
if name not in self._jobs:
|
|
46
|
+
raise KeyError(name)
|
|
47
|
+
if name in self._running:
|
|
48
|
+
return {"started": False, "reason": "already_running"}
|
|
49
|
+
|
|
50
|
+
run_id = await self._db.start_job_run(name, triggered_by=triggered_by)
|
|
51
|
+
task = asyncio.create_task(self._execute(name, run_id))
|
|
52
|
+
self._running[name] = task
|
|
53
|
+
return {"started": True, "run_id": run_id}
|
|
54
|
+
|
|
55
|
+
async def _execute(self, name: str, run_id: int):
|
|
56
|
+
func = self._jobs[name]["func"]
|
|
57
|
+
try:
|
|
58
|
+
result = await func()
|
|
59
|
+
detail = json.dumps(result) if result is not None else None
|
|
60
|
+
await self._db.finish_job_run(run_id, "completed", detail)
|
|
61
|
+
except asyncio.CancelledError:
|
|
62
|
+
await self._db.finish_job_run(run_id, "cancelled", None)
|
|
63
|
+
raise
|
|
64
|
+
except Exception as exc: # noqa: BLE001 - record any failure
|
|
65
|
+
logger.exception("Job '%s' failed", name)
|
|
66
|
+
await self._db.finish_job_run(
|
|
67
|
+
run_id, "failed", json.dumps({"error": f"{type(exc).__name__}: {exc}"})
|
|
68
|
+
)
|
|
69
|
+
finally:
|
|
70
|
+
self._running.pop(name, None)
|
|
@@ -116,7 +116,18 @@ async def insert_pay_queue(
|
|
|
116
116
|
# Target position: immediately after the LAST item in the persistent
|
|
117
117
|
# pay-queue list, or after the currently-playing item when none exist.
|
|
118
118
|
last_pay_uid = await db.get_last_pay_uid()
|
|
119
|
-
|
|
119
|
+
if last_pay_uid:
|
|
120
|
+
target_uid = last_pay_uid
|
|
121
|
+
else:
|
|
122
|
+
target_uid = await _now_playing_uid(api_gate, shadow)
|
|
123
|
+
if target_uid is None:
|
|
124
|
+
# No anchor to position against (robot KV not initialised).
|
|
125
|
+
# Cancel and refund rather than dumping the item at the end.
|
|
126
|
+
try:
|
|
127
|
+
await api_gate.queue_refund(username=username, request_id=request_id, reason="no_now_playing")
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
return {"success": False, "error": "Queue position unavailable (now-playing unknown); refunded"}
|
|
120
131
|
|
|
121
132
|
# Add to CyTube playlist (always appended; repositioned below)
|
|
122
133
|
try:
|
|
@@ -167,6 +178,7 @@ async def insert_pay_queue(
|
|
|
167
178
|
item = {
|
|
168
179
|
"uid": uid,
|
|
169
180
|
"title": title,
|
|
181
|
+
"friendly_token": _ft,
|
|
170
182
|
"media_type": media_type,
|
|
171
183
|
"media_id": media_id,
|
|
172
184
|
"duration_sec": duration_sec,
|
|
@@ -224,6 +236,13 @@ async def insert_pay_playnext(
|
|
|
224
236
|
|
|
225
237
|
# Target position: immediately after the currently-playing item.
|
|
226
238
|
target_uid = await _now_playing_uid(api_gate, shadow)
|
|
239
|
+
if target_uid is None:
|
|
240
|
+
# Cannot place "play next" without knowing the active item.
|
|
241
|
+
try:
|
|
242
|
+
await api_gate.queue_refund(username=username, request_id=request_id, reason="no_now_playing")
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
return {"success": False, "error": "Play-next unavailable (now-playing unknown); refunded"}
|
|
227
246
|
|
|
228
247
|
# Add to CyTube playlist (always appended; repositioned below)
|
|
229
248
|
try:
|
|
@@ -274,6 +293,7 @@ async def insert_pay_playnext(
|
|
|
274
293
|
item = {
|
|
275
294
|
"uid": uid,
|
|
276
295
|
"title": title,
|
|
296
|
+
"friendly_token": _ft,
|
|
277
297
|
"media_type": media_type,
|
|
278
298
|
"media_id": media_id,
|
|
279
299
|
"duration_sec": duration_sec,
|
|
@@ -297,6 +317,35 @@ async def insert_pay_playnext(
|
|
|
297
317
|
return {"success": True, "uid": uid, "request_id": request_id}
|
|
298
318
|
|
|
299
319
|
|
|
320
|
+
async def _refund_and_remove_pending_pay(api_gate, shadow, db) -> int:
|
|
321
|
+
"""Refund and remove every pending (up-next) paid item from the queue.
|
|
322
|
+
|
|
323
|
+
Returns the number of items removed. The currently-playing item is never
|
|
324
|
+
touched (it is not present in the pay shadow as an up-next item).
|
|
325
|
+
"""
|
|
326
|
+
pending = await db.get_pay_items()
|
|
327
|
+
np_uid = await _now_playing_uid(api_gate, shadow)
|
|
328
|
+
removed = 0
|
|
329
|
+
for it in pending:
|
|
330
|
+
uid = it.get("uid")
|
|
331
|
+
if uid is None or uid == np_uid:
|
|
332
|
+
continue
|
|
333
|
+
try:
|
|
334
|
+
await refund_item(api_gate=api_gate, db=db, uid=uid, reason="admin_playnext_refund")
|
|
335
|
+
except Exception:
|
|
336
|
+
logger.warning("Refund failed for uid %s during admin override", uid, exc_info=True)
|
|
337
|
+
try:
|
|
338
|
+
await api_gate.playlist_delete(uid)
|
|
339
|
+
except Exception:
|
|
340
|
+
logger.warning("Delete failed for uid %s during admin override", uid, exc_info=True)
|
|
341
|
+
try:
|
|
342
|
+
await shadow.remove(uid)
|
|
343
|
+
except Exception:
|
|
344
|
+
pass
|
|
345
|
+
removed += 1
|
|
346
|
+
return removed
|
|
347
|
+
|
|
348
|
+
|
|
300
349
|
async def insert_admin_queue(
|
|
301
350
|
*,
|
|
302
351
|
api_gate,
|
|
@@ -308,18 +357,39 @@ async def insert_admin_queue(
|
|
|
308
357
|
friendly_token: str | None = None,
|
|
309
358
|
title: str,
|
|
310
359
|
duration_sec: int,
|
|
360
|
+
mode: str = "after_purchased",
|
|
311
361
|
) -> dict:
|
|
312
|
-
"""Insert a zero-cost admin item
|
|
362
|
+
"""Insert a zero-cost admin item (no economy interaction).
|
|
363
|
+
|
|
364
|
+
``mode`` selects how the item is positioned:
|
|
313
365
|
|
|
314
|
-
|
|
315
|
-
|
|
366
|
+
- ``"after_purchased"`` (default): placed immediately after the last item in
|
|
367
|
+
the persistent pay-queue list, i.e. at the top of the free section.
|
|
368
|
+
- ``"playnext_refund"``: every pending (up-next) paid item is refunded and
|
|
369
|
+
removed, then the admin item is placed immediately after the now-playing
|
|
370
|
+
item.
|
|
371
|
+
- ``"cancel"``: no-op.
|
|
316
372
|
"""
|
|
373
|
+
if mode == "cancel":
|
|
374
|
+
return {"success": False, "error": "cancelled", "cancelled": True}
|
|
375
|
+
|
|
317
376
|
async with _queue_lock:
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
377
|
+
if mode == "playnext_refund":
|
|
378
|
+
removed = await _refund_and_remove_pending_pay(api_gate, shadow, db)
|
|
379
|
+
target_uid = await _now_playing_uid(api_gate, shadow)
|
|
380
|
+
if target_uid is None:
|
|
381
|
+
return {"success": False, "error": "Play-next unavailable (now-playing unknown)"}
|
|
382
|
+
else:
|
|
383
|
+
# after_purchased: immediately after the LAST persistent pay item,
|
|
384
|
+
# or after the currently-playing item when none exist.
|
|
385
|
+
removed = 0
|
|
386
|
+
last_pay_uid = await db.get_last_pay_uid()
|
|
387
|
+
if last_pay_uid:
|
|
388
|
+
target_uid = last_pay_uid
|
|
389
|
+
else:
|
|
390
|
+
target_uid = await _now_playing_uid(api_gate, shadow)
|
|
391
|
+
if target_uid is None:
|
|
392
|
+
return {"success": False, "error": "Queue position unavailable (now-playing unknown)"}
|
|
323
393
|
|
|
324
394
|
# Add to CyTube playlist (always appended; repositioned below)
|
|
325
395
|
try:
|
|
@@ -351,6 +421,7 @@ async def insert_admin_queue(
|
|
|
351
421
|
item = {
|
|
352
422
|
"uid": uid,
|
|
353
423
|
"title": title,
|
|
424
|
+
"friendly_token": _ft,
|
|
354
425
|
"media_type": media_type,
|
|
355
426
|
"media_id": media_id,
|
|
356
427
|
"duration_sec": duration_sec,
|
|
@@ -372,7 +443,8 @@ async def insert_admin_queue(
|
|
|
372
443
|
# Announce placement to the channel
|
|
373
444
|
await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
|
|
374
445
|
|
|
375
|
-
return {"success": True, "uid": uid}
|
|
446
|
+
return {"success": True, "uid": uid, "refunded": removed}
|
|
447
|
+
|
|
376
448
|
|
|
377
449
|
|
|
378
450
|
async def refund_item(*, api_gate, db, uid: int, reason: str) -> bool:
|
|
@@ -7,10 +7,11 @@ logger = logging.getLogger(__name__)
|
|
|
7
7
|
class StatePoller:
|
|
8
8
|
"""Polls api-gate at a fixed interval to keep QueueShadow in sync."""
|
|
9
9
|
|
|
10
|
-
def __init__(self, *, api_gate, shadow, ws_manager, interval: float = 3.0):
|
|
10
|
+
def __init__(self, *, api_gate, shadow, ws_manager, db=None, interval: float = 3.0):
|
|
11
11
|
self._api_gate = api_gate
|
|
12
12
|
self._shadow = shadow
|
|
13
13
|
self._ws_manager = ws_manager
|
|
14
|
+
self._db = db
|
|
14
15
|
self._interval = interval
|
|
15
16
|
self._task: asyncio.Task | None = None
|
|
16
17
|
|
|
@@ -34,7 +35,10 @@ class StatePoller:
|
|
|
34
35
|
now_playing = await self._api_gate.get_now_playing()
|
|
35
36
|
await self._shadow.apply_poll_result(playlist, now_playing)
|
|
36
37
|
# Broadcast updated state
|
|
37
|
-
|
|
38
|
+
if self._db is not None:
|
|
39
|
+
state = await self._shadow.get_enriched_state(self._db)
|
|
40
|
+
else:
|
|
41
|
+
state = self._shadow.get_queue_state()
|
|
38
42
|
await self._ws_manager.broadcast({"type": "queue_state", "data": state})
|
|
39
43
|
except asyncio.CancelledError:
|
|
40
44
|
raise
|
|
@@ -132,3 +132,57 @@ class QueueShadow:
|
|
|
132
132
|
"now_playing": self._now_playing,
|
|
133
133
|
"updated_at": datetime.now(UTC).isoformat(),
|
|
134
134
|
}
|
|
135
|
+
|
|
136
|
+
async def get_enriched_state(self, db) -> dict:
|
|
137
|
+
"""Queue state augmented with catalog metadata (cover art, etc.)."""
|
|
138
|
+
state = self.get_queue_state()
|
|
139
|
+
items = state.get("items") or []
|
|
140
|
+
now_playing = state.get("now_playing")
|
|
141
|
+
|
|
142
|
+
tokens, manifests = [], []
|
|
143
|
+
for it in items:
|
|
144
|
+
if it.get("friendly_token"):
|
|
145
|
+
tokens.append(it["friendly_token"])
|
|
146
|
+
if it.get("media_id"):
|
|
147
|
+
manifests.append(it["media_id"])
|
|
148
|
+
if now_playing:
|
|
149
|
+
if now_playing.get("friendly_token"):
|
|
150
|
+
tokens.append(now_playing["friendly_token"])
|
|
151
|
+
if now_playing.get("id"):
|
|
152
|
+
manifests.append(now_playing["id"])
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
lookup = await db.get_catalog_brief(tokens, manifests)
|
|
156
|
+
except Exception:
|
|
157
|
+
logger.warning("Failed to enrich queue state with catalog metadata", exc_info=True)
|
|
158
|
+
return state
|
|
159
|
+
|
|
160
|
+
def _meta_for(obj: dict, id_key: str) -> dict | None:
|
|
161
|
+
return lookup.get(obj.get("friendly_token") or "") or lookup.get(obj.get(id_key) or "")
|
|
162
|
+
|
|
163
|
+
enriched_items = []
|
|
164
|
+
for it in items:
|
|
165
|
+
meta = _meta_for(it, "media_id")
|
|
166
|
+
merged = dict(it)
|
|
167
|
+
if meta:
|
|
168
|
+
merged.setdefault("cover_art_path", meta.get("cover_art_path"))
|
|
169
|
+
merged.setdefault("thumbnail_url", meta.get("thumbnail_url"))
|
|
170
|
+
if not merged.get("title") or merged.get("title") == "Unknown":
|
|
171
|
+
merged["title"] = meta.get("title") or merged.get("title")
|
|
172
|
+
if not merged.get("friendly_token"):
|
|
173
|
+
merged["friendly_token"] = meta.get("friendly_token")
|
|
174
|
+
enriched_items.append(merged)
|
|
175
|
+
state["items"] = enriched_items
|
|
176
|
+
|
|
177
|
+
if now_playing:
|
|
178
|
+
meta = _meta_for(now_playing, "id")
|
|
179
|
+
np = dict(now_playing)
|
|
180
|
+
if meta:
|
|
181
|
+
np.setdefault("cover_art_path", meta.get("cover_art_path"))
|
|
182
|
+
np.setdefault("thumbnail_url", meta.get("thumbnail_url"))
|
|
183
|
+
if not np.get("friendly_token"):
|
|
184
|
+
np["friendly_token"] = meta.get("friendly_token")
|
|
185
|
+
state["now_playing"] = np
|
|
186
|
+
|
|
187
|
+
return state
|
|
188
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request, Depends, HTTPException
|
|
2
|
+
|
|
3
|
+
from ..auth.session import require_admin
|
|
4
|
+
|
|
5
|
+
router = APIRouter(prefix="/admin/jobs", tags=["admin"])
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.get("")
|
|
9
|
+
async def list_jobs(request: Request, user: dict = Depends(require_admin)):
|
|
10
|
+
"""List registered background jobs and whether each is running."""
|
|
11
|
+
job_manager = request.app.state.job_manager
|
|
12
|
+
return job_manager.list_jobs()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.get("/runs")
|
|
16
|
+
async def job_runs(request: Request, user: dict = Depends(require_admin), job: str | None = None, limit: int = 10):
|
|
17
|
+
"""Recent run history, optionally filtered by job name."""
|
|
18
|
+
db = request.app.state.db
|
|
19
|
+
return await db.get_job_runs(job_name=job, limit=limit)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post("/{name}/run")
|
|
23
|
+
async def run_job(request: Request, name: str, user: dict = Depends(require_admin)):
|
|
24
|
+
"""Trigger a registered job to run in the background."""
|
|
25
|
+
job_manager = request.app.state.job_manager
|
|
26
|
+
try:
|
|
27
|
+
result = await job_manager.run(name, triggered_by=user["username"])
|
|
28
|
+
except KeyError:
|
|
29
|
+
raise HTTPException(404, f"Unknown job: {name}")
|
|
30
|
+
if not result.get("started"):
|
|
31
|
+
raise HTTPException(409, result.get("reason", "Job already running"))
|
|
32
|
+
return {"success": True, **result}
|
|
@@ -8,11 +8,16 @@ router = APIRouter(prefix="/admin/queue", tags=["admin"])
|
|
|
8
8
|
|
|
9
9
|
@router.post("/add")
|
|
10
10
|
async def admin_add(request: Request, user: dict = Depends(require_admin)):
|
|
11
|
-
"""Queue an item as admin: zero cost,
|
|
11
|
+
"""Queue an item as admin: zero cost, position resolved by `mode`."""
|
|
12
12
|
body = await request.json()
|
|
13
13
|
friendly_token = body.get("friendly_token")
|
|
14
|
+
mode = body.get("mode", "after_purchased")
|
|
14
15
|
if not friendly_token:
|
|
15
16
|
raise HTTPException(400, "friendly_token required")
|
|
17
|
+
if mode not in ("after_purchased", "playnext_refund", "cancel"):
|
|
18
|
+
raise HTTPException(400, "invalid mode")
|
|
19
|
+
if mode == "cancel":
|
|
20
|
+
return {"success": False, "cancelled": True}
|
|
16
21
|
|
|
17
22
|
db = request.app.state.db
|
|
18
23
|
api_gate = request.app.state.api_gate
|
|
@@ -36,6 +41,7 @@ async def admin_add(request: Request, user: dict = Depends(require_admin)):
|
|
|
36
41
|
friendly_token=friendly_token,
|
|
37
42
|
title=item["title"],
|
|
38
43
|
duration_sec=item["duration_sec"],
|
|
44
|
+
mode=mode,
|
|
39
45
|
)
|
|
40
46
|
|
|
41
47
|
if not result["success"]:
|
|
@@ -91,6 +91,23 @@ async def queue_page(request: Request):
|
|
|
91
91
|
return templates.TemplateResponse(request, "queue/index.html", {"user": user})
|
|
92
92
|
|
|
93
93
|
|
|
94
|
+
@router.get("/catalog/item/{friendly_token}", response_class=HTMLResponse)
|
|
95
|
+
async def catalog_item_page(request: Request, friendly_token: str):
|
|
96
|
+
user = _get_user_or_none(request)
|
|
97
|
+
if not user:
|
|
98
|
+
return RedirectResponse("/auth/login")
|
|
99
|
+
db = request.app.state.db
|
|
100
|
+
item = await db.get_item_admin(friendly_token)
|
|
101
|
+
if not item:
|
|
102
|
+
return templates.TemplateResponse(
|
|
103
|
+
request, "catalog/item_not_found.html", {"user": user}, status_code=404
|
|
104
|
+
)
|
|
105
|
+
return templates.TemplateResponse(request, "catalog/item_detail.html", {
|
|
106
|
+
"user": user,
|
|
107
|
+
"item": item,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
|
|
94
111
|
@router.get("/user/dashboard", response_class=HTMLResponse)
|
|
95
112
|
async def user_dashboard_page(request: Request):
|
|
96
113
|
user = _get_user_or_none(request)
|
|
@@ -10,7 +10,8 @@ router = APIRouter(prefix="/queue", tags=["queue"])
|
|
|
10
10
|
async def get_queue_state(request: Request, user: dict = Depends(get_current_user)):
|
|
11
11
|
"""Get current queue state."""
|
|
12
12
|
shadow = request.app.state.shadow
|
|
13
|
-
|
|
13
|
+
db = request.app.state.db
|
|
14
|
+
return await shadow.get_enriched_state(db)
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
@router.post("/add")
|
|
@@ -128,7 +129,12 @@ async def play_next(request: Request, user: dict = Depends(get_current_user)):
|
|
|
128
129
|
@router.get("/preview")
|
|
129
130
|
async def cost_preview(request: Request, friendly_token: str, tier: str = "queue",
|
|
130
131
|
user: dict = Depends(get_current_user)):
|
|
131
|
-
"""Preview cost
|
|
132
|
+
"""Preview the cost of queuing an item as a confirmation receipt.
|
|
133
|
+
|
|
134
|
+
Returns the catalog title, pricing breakdown (base cost, discount, total)
|
|
135
|
+
and the user's balance before/after the transaction so the UI can show a
|
|
136
|
+
receipt before the user confirms.
|
|
137
|
+
"""
|
|
132
138
|
db = request.app.state.db
|
|
133
139
|
api_gate = request.app.state.api_gate
|
|
134
140
|
|
|
@@ -141,7 +147,38 @@ async def cost_preview(request: Request, friendly_token: str, tier: str = "queue
|
|
|
141
147
|
duration_sec=item["duration_sec"],
|
|
142
148
|
tier=tier,
|
|
143
149
|
)
|
|
144
|
-
|
|
150
|
+
|
|
151
|
+
cost_z = preview.get("cost_z")
|
|
152
|
+
discount_pct = preview.get("discount_pct", 0) or 0
|
|
153
|
+
# base_cost is provided by newer economy builds; derive it as a fallback.
|
|
154
|
+
base_cost = preview.get("base_cost")
|
|
155
|
+
if base_cost is None and cost_z is not None:
|
|
156
|
+
if discount_pct and discount_pct < 100:
|
|
157
|
+
base_cost = round(cost_z / (1 - discount_pct / 100))
|
|
158
|
+
else:
|
|
159
|
+
base_cost = cost_z
|
|
160
|
+
discount_amount = (base_cost - cost_z) if (base_cost is not None and cost_z is not None) else 0
|
|
161
|
+
|
|
162
|
+
balance = None
|
|
163
|
+
try:
|
|
164
|
+
bal = await api_gate.get_balance(user["username"])
|
|
165
|
+
balance = bal.get("balance")
|
|
166
|
+
except Exception:
|
|
167
|
+
balance = None
|
|
168
|
+
|
|
169
|
+
balance_after = (balance - cost_z) if (balance is not None and cost_z is not None) else None
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
**preview,
|
|
173
|
+
"friendly_token": friendly_token,
|
|
174
|
+
"title": item["title"],
|
|
175
|
+
"duration_sec": item["duration_sec"],
|
|
176
|
+
"tier": tier,
|
|
177
|
+
"base_cost": base_cost,
|
|
178
|
+
"discount_amount": discount_amount,
|
|
179
|
+
"balance": balance,
|
|
180
|
+
"balance_after": balance_after,
|
|
181
|
+
}
|
|
145
182
|
|
|
146
183
|
|
|
147
184
|
@router.get("/history")
|