kryten-webqueue 0.5.2__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.
Files changed (68) hide show
  1. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/CHANGELOG.md +22 -0
  2. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/app.py +13 -0
  4. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/catalog/db.py +63 -0
  5. kryten_webqueue-0.6.0/kryten_webqueue/jobs/__init__.py +3 -0
  6. kryten_webqueue-0.6.0/kryten_webqueue/jobs/manager.py +70 -0
  7. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/queue/ordering.py +82 -10
  8. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/queue/poller.py +6 -2
  9. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/queue/shadow.py +54 -0
  10. kryten_webqueue-0.6.0/kryten_webqueue/routes/admin_jobs.py +32 -0
  11. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/admin_queue.py +7 -1
  12. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/pages.py +17 -0
  13. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/queue.py +2 -1
  14. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/static/css/main.css +71 -0
  15. kryten_webqueue-0.6.0/kryten_webqueue/static/js/main.js +132 -0
  16. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/admin/index.html +62 -3
  17. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/base.html +3 -3
  18. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/catalog/browse.html +6 -60
  19. kryten_webqueue-0.6.0/kryten_webqueue/templates/catalog/item_detail.html +48 -0
  20. kryten_webqueue-0.6.0/kryten_webqueue/templates/catalog/item_not_found.html +10 -0
  21. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/queue/index.html +15 -1
  22. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/pyproject.toml +1 -1
  23. kryten_webqueue-0.5.2/kryten_webqueue/static/js/main.js +0 -26
  24. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/.github/workflows/python-publish.yml +0 -0
  25. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/.github/workflows/release.yml +0 -0
  26. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/.gitignore +0 -0
  27. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/README.md +0 -0
  28. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/config.example.json +0 -0
  29. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/deploy/kryten-webqueue.service +0 -0
  30. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/deploy/nginx-queue.conf +0 -0
  31. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  32. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/docs/IMPL_API_GATE.md +0 -0
  33. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/docs/IMPL_ECONOMY.md +0 -0
  34. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  35. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/docs/IMPL_ROBOT.md +0 -0
  36. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/docs/PRE_PLAN_GAPS.md +0 -0
  37. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/docs/PRODUCT_PLAN.md +0 -0
  38. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/__init__.py +0 -0
  39. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/__main__.py +0 -0
  40. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  41. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/api_gate/client.py +0 -0
  42. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/auth/__init__.py +0 -0
  43. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/auth/otp.py +0 -0
  44. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  45. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/auth/session.py +0 -0
  46. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/catalog/__init__.py +0 -0
  47. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/catalog/images.py +0 -0
  48. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/catalog/sync.py +0 -0
  49. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/config.py +0 -0
  50. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/playlists/__init__.py +0 -0
  51. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/playlists/fire.py +0 -0
  52. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/playlists/importer.py +0 -0
  53. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  54. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/queue/__init__.py +0 -0
  55. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/__init__.py +0 -0
  56. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  57. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  58. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/auth.py +0 -0
  59. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/catalog.py +0 -0
  60. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/user.py +0 -0
  61. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  62. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  63. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  64. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/auth/login.html +0 -0
  65. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  66. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/ws/__init__.py +0 -0
  67. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/ws/handler.py +0 -0
  68. {kryten_webqueue-0.5.2 → kryten_webqueue-0.6.0}/kryten_webqueue/ws/manager.py +0 -0
@@ -5,6 +5,28 @@ 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
+
8
30
  ## [0.5.2] - 2026-06-05
9
31
 
10
32
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -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,3 @@
1
+ from .manager import JobManager
2
+
3
+ __all__ = ["JobManager"]
@@ -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
- target_uid = last_pay_uid if last_pay_uid else await _now_playing_uid(api_gate, shadow)
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 in the first available non-pay slot.
362
+ """Insert a zero-cost admin item (no economy interaction).
363
+
364
+ ``mode`` selects how the item is positioned:
313
365
 
314
- Treated exactly like a non-paid item (no economy interaction). It is placed
315
- immediately after the last paid item, i.e. at the top of the free section.
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
- # Target position: immediately after the LAST item in the persistent
319
- # pay-queue list, or after the currently-playing item when none exist.
320
- # The admin item itself is NOT added to the persistent pay list.
321
- last_pay_uid = await db.get_last_pay_uid()
322
- target_uid = last_pay_uid if last_pay_uid else await _now_playing_uid(api_gate, shadow)
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
- state = self._shadow.get_queue_state()
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, first available non-pay slot."""
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
- return shadow.get_queue_state()
13
+ db = request.app.state.db
14
+ return await shadow.get_enriched_state(db)
14
15
 
15
16
 
16
17
  @router.post("/add")
@@ -232,6 +232,13 @@ a:hover {
232
232
  background: var(--bg-card);
233
233
  border-radius: var(--radius);
234
234
  padding: 1.5rem;
235
+ display: flex;
236
+ gap: 1rem;
237
+ align-items: flex-start;
238
+ }
239
+ .np-info {
240
+ flex: 1;
241
+ min-width: 0;
235
242
  }
236
243
  .np-info h3 {
237
244
  margin-bottom: 0.5rem;
@@ -281,6 +288,46 @@ a:hover {
281
288
  color: var(--text-secondary);
282
289
  min-width: 1.5rem;
283
290
  }
291
+ .qi-drag {
292
+ cursor: grab;
293
+ color: var(--text-secondary);
294
+ font-size: 1rem;
295
+ user-select: none;
296
+ line-height: 1;
297
+ }
298
+ .qi-cover {
299
+ width: 48px;
300
+ height: 48px;
301
+ flex-shrink: 0;
302
+ border-radius: 4px;
303
+ overflow: hidden;
304
+ background: var(--bg-elevated, #222);
305
+ }
306
+ .qi-cover img,
307
+ .np-cover img {
308
+ width: 100%;
309
+ height: 100%;
310
+ object-fit: cover;
311
+ display: block;
312
+ }
313
+ .np-cover {
314
+ width: 96px;
315
+ height: 96px;
316
+ flex-shrink: 0;
317
+ border-radius: 6px;
318
+ overflow: hidden;
319
+ background: var(--bg-elevated, #222);
320
+ }
321
+ .cover-placeholder {
322
+ width: 100%;
323
+ height: 100%;
324
+ display: flex;
325
+ align-items: center;
326
+ justify-content: center;
327
+ font-weight: 700;
328
+ color: var(--text-secondary);
329
+ text-transform: uppercase;
330
+ }
284
331
  .qi-info {
285
332
  flex: 1;
286
333
  min-width: 0;
@@ -488,6 +535,30 @@ a:hover {
488
535
  color: var(--text-secondary);
489
536
  }
490
537
 
538
+ /* Jobs */
539
+ .jobs-list {
540
+ display: flex;
541
+ flex-direction: column;
542
+ gap: 0.5rem;
543
+ margin-bottom: 1rem;
544
+ }
545
+ .job-row {
546
+ display: flex;
547
+ align-items: center;
548
+ justify-content: space-between;
549
+ gap: 1rem;
550
+ padding: 0.5rem 0.75rem;
551
+ background: var(--bg-card);
552
+ border-radius: var(--radius);
553
+ }
554
+ .job-status {
555
+ text-transform: capitalize;
556
+ }
557
+ .job-status-completed { color: var(--success); }
558
+ .job-status-failed { color: var(--danger); }
559
+ .job-status-running { color: var(--accent); }
560
+ .job-status-cancelled { color: var(--text-secondary); }
561
+
491
562
  /* Empty state */
492
563
  .empty-state {
493
564
  color: var(--text-secondary);