kryten-webqueue 0.5.1__tar.gz → 0.6.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.
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/CHANGELOG.md +34 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/app.py +13 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/catalog/db.py +63 -0
- kryten_webqueue-0.6.0/kryten_webqueue/jobs/__init__.py +3 -0
- kryten_webqueue-0.6.0/kryten_webqueue/jobs/manager.py +70 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/queue/ordering.py +155 -50
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/queue/poller.py +6 -2
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/queue/shadow.py +54 -0
- kryten_webqueue-0.6.0/kryten_webqueue/routes/admin_jobs.py +32 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/admin_queue.py +7 -1
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/pages.py +17 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/queue.py +2 -1
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/static/css/main.css +71 -0
- kryten_webqueue-0.6.0/kryten_webqueue/static/js/main.js +132 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/admin/index.html +62 -3
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/base.html +3 -3
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/catalog/browse.html +6 -60
- kryten_webqueue-0.6.0/kryten_webqueue/templates/catalog/item_detail.html +48 -0
- kryten_webqueue-0.6.0/kryten_webqueue/templates/catalog/item_not_found.html +10 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/queue/index.html +15 -1
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/pyproject.toml +1 -1
- kryten_webqueue-0.5.1/kryten_webqueue/static/js/main.js +0 -26
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/.gitignore +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/README.md +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/config.example.json +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/ws/manager.py +0 -0
|
@@ -5,6 +5,40 @@ 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.0] - 2026-06-05
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
- **Admin job routes** — `GET /admin/jobs`, `GET /admin/jobs/runs`, and `POST /admin/jobs/{name}/run`.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **Top-bar rebrand to "Channel-Z"** — The navigation brand and page-title suffix now read "Channel-Z" instead of "DropSugar Queue"/"DropSugar".
|
|
20
|
+
- **Footer credit links to GitHub** — "kryten-webqueue" in the footer now links to the project's GitHub repository.
|
|
21
|
+
- **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.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **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.
|
|
26
|
+
- **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*.
|
|
27
|
+
|
|
28
|
+
[0.6.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.6.0
|
|
29
|
+
|
|
30
|
+
## [0.5.2] - 2026-06-05
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **Queue items now land in the correct playlist position** — Positioning is computed relative to the currently-playing item and the persistent pay-queue list (`queue_shadow` rows with `is_pay = 1`):
|
|
35
|
+
- **Play Next** — moved to immediately *after* the currently-playing item (previously `prepend`, which placed it before the active item). Existing pay items shift down one position.
|
|
36
|
+
- **Queue** — moved to immediately after the *last* item in the persistent pay-queue list, or after the currently-playing item when no pay items exist (previously left at the end of the playlist when the pay list was empty).
|
|
37
|
+
- **Queue as Admin** — same target as Queue, but the item is *not* added to the persistent pay list (`is_pay = 0`).
|
|
38
|
+
- All paths now add to CyTube with `position="end"` and then issue a single `move` to the resolved target UID, refunding (where applicable) and removing the orphaned item if the move fails.
|
|
39
|
+
|
|
40
|
+
[0.5.2]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.5.2
|
|
41
|
+
|
|
8
42
|
## [0.5.1] - 2026-06-05
|
|
9
43
|
|
|
10
44
|
### Added
|
|
@@ -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)
|
|
@@ -50,6 +50,39 @@ async def _announce_queued(api_gate, shadow, *, uid: int, title: str, username:
|
|
|
50
50
|
logger.warning("Failed to send queue announcement", exc_info=True)
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
async def _now_playing_uid(api_gate, shadow) -> int | None:
|
|
54
|
+
"""UID of the currently-playing item, preferring fresh state over the cache."""
|
|
55
|
+
np = None
|
|
56
|
+
try:
|
|
57
|
+
np = await api_gate.get_now_playing()
|
|
58
|
+
except Exception:
|
|
59
|
+
np = None
|
|
60
|
+
if not np:
|
|
61
|
+
np = shadow.now_playing
|
|
62
|
+
if not np:
|
|
63
|
+
return None
|
|
64
|
+
uid = np.get("uid")
|
|
65
|
+
try:
|
|
66
|
+
return int(uid) if uid is not None else None
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _shadow_index_after_uid(shadow, target_uid: int | None) -> int:
|
|
72
|
+
"""Shadow list index immediately after target_uid (end of list if not found)."""
|
|
73
|
+
if target_uid is not None:
|
|
74
|
+
for idx, it in enumerate(shadow.items):
|
|
75
|
+
if it.get("uid") == target_uid:
|
|
76
|
+
return idx + 1
|
|
77
|
+
return len(shadow.items)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _move_after(api_gate, *, uid: int, target_uid: int | None) -> None:
|
|
81
|
+
"""Move uid to immediately after target_uid. No-op when target is None."""
|
|
82
|
+
if target_uid is not None:
|
|
83
|
+
await api_gate.playlist_move(uid, target_uid)
|
|
84
|
+
|
|
85
|
+
|
|
53
86
|
async def insert_pay_queue(
|
|
54
87
|
*,
|
|
55
88
|
api_gate,
|
|
@@ -80,16 +113,28 @@ async def insert_pay_queue(
|
|
|
80
113
|
except httpx.HTTPStatusError as exc:
|
|
81
114
|
return {"success": False, "error": f"Spend failed: {exc.response.status_code}"}
|
|
82
115
|
|
|
83
|
-
#
|
|
116
|
+
# Target position: immediately after the LAST item in the persistent
|
|
117
|
+
# pay-queue list, or after the currently-playing item when none exist.
|
|
84
118
|
last_pay_uid = await db.get_last_pay_uid()
|
|
85
|
-
|
|
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"}
|
|
86
131
|
|
|
87
|
-
# Add to CyTube playlist
|
|
132
|
+
# Add to CyTube playlist (always appended; repositioned below)
|
|
88
133
|
try:
|
|
89
134
|
add_result = await api_gate.playlist_add(
|
|
90
135
|
media_type=media_type,
|
|
91
136
|
media_id=media_id,
|
|
92
|
-
position=
|
|
137
|
+
position="end",
|
|
93
138
|
)
|
|
94
139
|
except httpx.HTTPStatusError as exc:
|
|
95
140
|
try:
|
|
@@ -107,20 +152,19 @@ async def insert_pay_queue(
|
|
|
107
152
|
|
|
108
153
|
uid = add_result["uid"]
|
|
109
154
|
|
|
110
|
-
# Move after
|
|
111
|
-
|
|
155
|
+
# Move after the target UID; refund + remove if positioning fails
|
|
156
|
+
try:
|
|
157
|
+
await _move_after(api_gate, uid=uid, target_uid=target_uid)
|
|
158
|
+
except httpx.HTTPStatusError:
|
|
112
159
|
try:
|
|
113
|
-
await api_gate.
|
|
114
|
-
except
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
except Exception:
|
|
122
|
-
pass
|
|
123
|
-
return {"success": False, "error": "Failed to position item in queue"}
|
|
160
|
+
await api_gate.queue_refund(username=username, request_id=request_id, reason="move_failed")
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
try:
|
|
164
|
+
await api_gate.playlist_delete(uid)
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
return {"success": False, "error": "Failed to position item in queue"}
|
|
124
168
|
|
|
125
169
|
# Record spend
|
|
126
170
|
_ft = friendly_token if friendly_token is not None else (media_id if media_type == "cm" else None)
|
|
@@ -134,6 +178,7 @@ async def insert_pay_queue(
|
|
|
134
178
|
item = {
|
|
135
179
|
"uid": uid,
|
|
136
180
|
"title": title,
|
|
181
|
+
"friendly_token": _ft,
|
|
137
182
|
"media_type": media_type,
|
|
138
183
|
"media_id": media_id,
|
|
139
184
|
"duration_sec": duration_sec,
|
|
@@ -143,11 +188,8 @@ async def insert_pay_queue(
|
|
|
143
188
|
"z_cost": z_cost,
|
|
144
189
|
"schedule_id": None,
|
|
145
190
|
}
|
|
146
|
-
# Position after
|
|
147
|
-
|
|
148
|
-
pos = await db.get_shadow_position_after(last_pay_uid)
|
|
149
|
-
else:
|
|
150
|
-
pos = len(shadow.items)
|
|
191
|
+
# Position immediately after the target UID
|
|
192
|
+
pos = _shadow_index_after_uid(shadow, target_uid)
|
|
151
193
|
await shadow.insert_at(item, pos)
|
|
152
194
|
|
|
153
195
|
# Queue history
|
|
@@ -192,7 +234,17 @@ async def insert_pay_playnext(
|
|
|
192
234
|
except httpx.HTTPStatusError as exc:
|
|
193
235
|
return {"success": False, "error": f"Spend failed: {exc.response.status_code}"}
|
|
194
236
|
|
|
195
|
-
#
|
|
237
|
+
# Target position: immediately after the currently-playing item.
|
|
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"}
|
|
246
|
+
|
|
247
|
+
# Add to CyTube playlist (always appended; repositioned below)
|
|
196
248
|
try:
|
|
197
249
|
add_result = await api_gate.playlist_add(
|
|
198
250
|
media_type=media_type,
|
|
@@ -214,9 +266,9 @@ async def insert_pay_playnext(
|
|
|
214
266
|
|
|
215
267
|
uid = add_result["uid"]
|
|
216
268
|
|
|
217
|
-
# Move to
|
|
269
|
+
# Move to immediately after the now-playing item; refund + remove on failure
|
|
218
270
|
try:
|
|
219
|
-
await api_gate
|
|
271
|
+
await _move_after(api_gate, uid=uid, target_uid=target_uid)
|
|
220
272
|
except httpx.HTTPStatusError:
|
|
221
273
|
try:
|
|
222
274
|
await api_gate.queue_refund(username=username, request_id=request_id, reason="move_failed")
|
|
@@ -236,10 +288,12 @@ async def insert_pay_playnext(
|
|
|
236
288
|
tier=tier, z_cost=z_cost,
|
|
237
289
|
)
|
|
238
290
|
|
|
239
|
-
# Update local shadow
|
|
291
|
+
# Update local shadow immediately after now-playing. Existing pay items
|
|
292
|
+
# shift down one position as insert_at re-indexes the list.
|
|
240
293
|
item = {
|
|
241
294
|
"uid": uid,
|
|
242
295
|
"title": title,
|
|
296
|
+
"friendly_token": _ft,
|
|
243
297
|
"media_type": media_type,
|
|
244
298
|
"media_id": media_id,
|
|
245
299
|
"duration_sec": duration_sec,
|
|
@@ -249,7 +303,8 @@ async def insert_pay_playnext(
|
|
|
249
303
|
"z_cost": z_cost,
|
|
250
304
|
"schedule_id": None,
|
|
251
305
|
}
|
|
252
|
-
|
|
306
|
+
pos = _shadow_index_after_uid(shadow, target_uid)
|
|
307
|
+
await shadow.insert_at(item, pos)
|
|
253
308
|
|
|
254
309
|
await db.add_queue_history(
|
|
255
310
|
username=username, friendly_token=_ft,
|
|
@@ -262,6 +317,35 @@ async def insert_pay_playnext(
|
|
|
262
317
|
return {"success": True, "uid": uid, "request_id": request_id}
|
|
263
318
|
|
|
264
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
|
+
|
|
265
349
|
async def insert_admin_queue(
|
|
266
350
|
*,
|
|
267
351
|
api_gate,
|
|
@@ -273,23 +357,46 @@ async def insert_admin_queue(
|
|
|
273
357
|
friendly_token: str | None = None,
|
|
274
358
|
title: str,
|
|
275
359
|
duration_sec: int,
|
|
360
|
+
mode: str = "after_purchased",
|
|
276
361
|
) -> dict:
|
|
277
|
-
"""Insert a zero-cost admin item
|
|
362
|
+
"""Insert a zero-cost admin item (no economy interaction).
|
|
278
363
|
|
|
279
|
-
|
|
280
|
-
|
|
364
|
+
``mode`` selects how the item is positioned:
|
|
365
|
+
|
|
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.
|
|
281
372
|
"""
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
last_pay_uid = await db.get_last_pay_uid()
|
|
285
|
-
position = "end" if not last_pay_uid else str(last_pay_uid)
|
|
373
|
+
if mode == "cancel":
|
|
374
|
+
return {"success": False, "error": "cancelled", "cancelled": True}
|
|
286
375
|
|
|
287
|
-
|
|
376
|
+
async with _queue_lock:
|
|
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)"}
|
|
393
|
+
|
|
394
|
+
# Add to CyTube playlist (always appended; repositioned below)
|
|
288
395
|
try:
|
|
289
396
|
add_result = await api_gate.playlist_add(
|
|
290
397
|
media_type=media_type,
|
|
291
398
|
media_id=media_id,
|
|
292
|
-
position=
|
|
399
|
+
position="end",
|
|
293
400
|
)
|
|
294
401
|
except httpx.HTTPStatusError as exc:
|
|
295
402
|
return {"success": False, "error": _add_failure_reason(None, exc)}
|
|
@@ -298,16 +405,15 @@ async def insert_admin_queue(
|
|
|
298
405
|
|
|
299
406
|
uid = add_result["uid"]
|
|
300
407
|
|
|
301
|
-
# Move
|
|
302
|
-
|
|
408
|
+
# Move after the target UID; remove the orphan if positioning fails
|
|
409
|
+
try:
|
|
410
|
+
await _move_after(api_gate, uid=uid, target_uid=target_uid)
|
|
411
|
+
except httpx.HTTPStatusError:
|
|
303
412
|
try:
|
|
304
|
-
await api_gate.
|
|
305
|
-
except
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
except Exception:
|
|
309
|
-
pass
|
|
310
|
-
return {"success": False, "error": "Failed to position item in queue"}
|
|
413
|
+
await api_gate.playlist_delete(uid)
|
|
414
|
+
except Exception:
|
|
415
|
+
pass
|
|
416
|
+
return {"success": False, "error": "Failed to position item in queue"}
|
|
311
417
|
|
|
312
418
|
_ft = friendly_token if friendly_token is not None else (media_id if media_type == "cm" else None)
|
|
313
419
|
|
|
@@ -315,6 +421,7 @@ async def insert_admin_queue(
|
|
|
315
421
|
item = {
|
|
316
422
|
"uid": uid,
|
|
317
423
|
"title": title,
|
|
424
|
+
"friendly_token": _ft,
|
|
318
425
|
"media_type": media_type,
|
|
319
426
|
"media_id": media_id,
|
|
320
427
|
"duration_sec": duration_sec,
|
|
@@ -324,10 +431,7 @@ async def insert_admin_queue(
|
|
|
324
431
|
"z_cost": None,
|
|
325
432
|
"schedule_id": None,
|
|
326
433
|
}
|
|
327
|
-
|
|
328
|
-
pos = await db.get_shadow_position_after(last_pay_uid)
|
|
329
|
-
else:
|
|
330
|
-
pos = len(shadow.items)
|
|
434
|
+
pos = _shadow_index_after_uid(shadow, target_uid)
|
|
331
435
|
await shadow.insert_at(item, pos)
|
|
332
436
|
|
|
333
437
|
# Queue history (zero cost, admin tier)
|
|
@@ -339,7 +443,8 @@ async def insert_admin_queue(
|
|
|
339
443
|
# Announce placement to the channel
|
|
340
444
|
await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
|
|
341
445
|
|
|
342
|
-
return {"success": True, "uid": uid}
|
|
446
|
+
return {"success": True, "uid": uid, "refunded": removed}
|
|
447
|
+
|
|
343
448
|
|
|
344
449
|
|
|
345
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
|
+
|