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.
Files changed (68) hide show
  1. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/CHANGELOG.md +34 -0
  2. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/app.py +13 -0
  4. {kryten_webqueue-0.5.1 → 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.1 → kryten_webqueue-0.6.0}/kryten_webqueue/queue/ordering.py +155 -50
  8. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/queue/poller.py +6 -2
  9. {kryten_webqueue-0.5.1 → 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.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/admin_queue.py +7 -1
  12. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/pages.py +17 -0
  13. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/queue.py +2 -1
  14. {kryten_webqueue-0.5.1 → 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.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/admin/index.html +62 -3
  17. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/base.html +3 -3
  18. {kryten_webqueue-0.5.1 → 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.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/queue/index.html +15 -1
  22. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/pyproject.toml +1 -1
  23. kryten_webqueue-0.5.1/kryten_webqueue/static/js/main.js +0 -26
  24. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/.github/workflows/python-publish.yml +0 -0
  25. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/.github/workflows/release.yml +0 -0
  26. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/.gitignore +0 -0
  27. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/README.md +0 -0
  28. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/config.example.json +0 -0
  29. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/deploy/kryten-webqueue.service +0 -0
  30. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/deploy/nginx-queue.conf +0 -0
  31. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  32. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/docs/IMPL_API_GATE.md +0 -0
  33. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/docs/IMPL_ECONOMY.md +0 -0
  34. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  35. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/docs/IMPL_ROBOT.md +0 -0
  36. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/docs/PRE_PLAN_GAPS.md +0 -0
  37. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/docs/PRODUCT_PLAN.md +0 -0
  38. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/__init__.py +0 -0
  39. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/__main__.py +0 -0
  40. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  41. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/api_gate/client.py +0 -0
  42. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/auth/__init__.py +0 -0
  43. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/auth/otp.py +0 -0
  44. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  45. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/auth/session.py +0 -0
  46. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/catalog/__init__.py +0 -0
  47. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/catalog/images.py +0 -0
  48. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/catalog/sync.py +0 -0
  49. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/config.py +0 -0
  50. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/playlists/__init__.py +0 -0
  51. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/playlists/fire.py +0 -0
  52. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/playlists/importer.py +0 -0
  53. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  54. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/queue/__init__.py +0 -0
  55. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/__init__.py +0 -0
  56. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  57. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  58. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/auth.py +0 -0
  59. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/catalog.py +0 -0
  60. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/routes/user.py +0 -0
  61. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  62. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  63. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  64. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/auth/login.html +0 -0
  65. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  66. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/ws/__init__.py +0 -0
  67. {kryten_webqueue-0.5.1 → kryten_webqueue-0.6.0}/kryten_webqueue/ws/handler.py +0 -0
  68. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.5.1
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)
@@ -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
- # Find position: after last pay item, or prepend if none
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
- position = "end" if not last_pay_uid else str(last_pay_uid)
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=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 last pay UID if needed; refund + remove if positioning fails
111
- if last_pay_uid:
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.playlist_move(uid, last_pay_uid)
114
- except httpx.HTTPStatusError:
115
- try:
116
- await api_gate.queue_refund(username=username, request_id=request_id, reason="move_failed")
117
- except Exception:
118
- pass
119
- try:
120
- await api_gate.playlist_delete(uid)
121
- except Exception:
122
- pass
123
- return {"success": False, "error": "Failed to position item in queue"}
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 last pay
147
- if last_pay_uid:
148
- pos = await db.get_shadow_position_after(last_pay_uid)
149
- else:
150
- pos = len(shadow.items)
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
- # Add to CyTube playlist at prepend position
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 front; refund + remove if positioning fails
269
+ # Move to immediately after the now-playing item; refund + remove on failure
218
270
  try:
219
- await api_gate.playlist_move(uid, "prepend")
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 at position 0
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
- await shadow.insert_at(item, 0)
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 in the first available non-pay slot.
362
+ """Insert a zero-cost admin item (no economy interaction).
278
363
 
279
- Treated exactly like a non-paid item (no economy interaction). It is placed
280
- immediately after the last paid item, i.e. at the top of the free section.
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
- async with _queue_lock:
283
- # First available non-pay slot is right after the last pay item.
284
- last_pay_uid = await db.get_last_pay_uid()
285
- position = "end" if not last_pay_uid else str(last_pay_uid)
373
+ if mode == "cancel":
374
+ return {"success": False, "error": "cancelled", "cancelled": True}
286
375
 
287
- # Add to CyTube playlist
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=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 to the top of the free section if there are pay items above
302
- if last_pay_uid:
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.playlist_move(uid, last_pay_uid)
305
- except httpx.HTTPStatusError:
306
- try:
307
- await api_gate.playlist_delete(uid)
308
- except Exception:
309
- pass
310
- return {"success": False, "error": "Failed to position item in queue"}
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
- if last_pay_uid:
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
- 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
+