kryten-webqueue 0.7.4__tar.gz → 0.8.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 (70) hide show
  1. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/CHANGELOG.md +25 -0
  2. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/db.py +106 -17
  4. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_playlists.py +15 -0
  5. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/catalog.py +8 -6
  6. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/pages.py +20 -10
  7. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/queue.py +40 -2
  8. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/static/css/main.css +162 -8
  9. kryten_webqueue-0.8.0/kryten_webqueue/templates/admin/playlists.html +303 -0
  10. kryten_webqueue-0.8.0/kryten_webqueue/templates/admin/queue_mgmt.html +186 -0
  11. kryten_webqueue-0.8.0/kryten_webqueue/templates/admin/schedules.html +179 -0
  12. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/base.html +3 -0
  13. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/catalog/browse.html +10 -1
  14. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -1
  15. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/queue/index.html +40 -0
  16. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/pyproject.toml +1 -1
  17. kryten_webqueue-0.7.4/kryten_webqueue/templates/admin/playlists.html +0 -14
  18. kryten_webqueue-0.7.4/kryten_webqueue/templates/admin/queue_mgmt.html +0 -14
  19. kryten_webqueue-0.7.4/kryten_webqueue/templates/admin/schedules.html +0 -14
  20. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/.github/workflows/python-publish.yml +0 -0
  21. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/.github/workflows/release.yml +0 -0
  22. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/.gitignore +0 -0
  23. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/README.md +0 -0
  24. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/config.example.json +0 -0
  25. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/deploy/kryten-webqueue.service +0 -0
  26. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/deploy/nginx-queue.conf +0 -0
  27. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  28. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/docs/IMPL_API_GATE.md +0 -0
  29. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/docs/IMPL_ECONOMY.md +0 -0
  30. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  31. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/docs/IMPL_ROBOT.md +0 -0
  32. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/docs/PRE_PLAN_GAPS.md +0 -0
  33. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/docs/PRODUCT_PLAN.md +0 -0
  34. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/__init__.py +0 -0
  35. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/__main__.py +0 -0
  36. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  37. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/api_gate/client.py +0 -0
  38. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/app.py +0 -0
  39. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/__init__.py +0 -0
  40. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/otp.py +0 -0
  41. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  42. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/session.py +0 -0
  43. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/__init__.py +0 -0
  44. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/images.py +0 -0
  45. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/sync.py +0 -0
  46. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/config.py +0 -0
  47. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/jobs/__init__.py +0 -0
  48. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/jobs/manager.py +0 -0
  49. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/__init__.py +0 -0
  50. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/fire.py +0 -0
  51. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/importer.py +0 -0
  52. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  53. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/__init__.py +0 -0
  54. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/ordering.py +0 -0
  55. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/poller.py +0 -0
  56. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/shadow.py +0 -0
  57. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/__init__.py +0 -0
  58. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  59. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  60. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  61. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/auth.py +0 -0
  62. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/user.py +0 -0
  63. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/static/js/main.js +0 -0
  64. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/admin/index.html +0 -0
  65. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/auth/login.html +0 -0
  66. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  67. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  68. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/ws/__init__.py +0 -0
  69. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/ws/handler.py +0 -0
  70. {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/ws/manager.py +0 -0
@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
4
4
 
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
+ ## [0.8.0] - 2026-06-08
8
+
9
+ ### Added
10
+
11
+ - **Admin Playlists UI.** The placeholder is replaced with a full management page: list/create/delete saved playlists, a two-column item editor (catalog search-to-add, drag-and-drop plus up/down reorder, per-item remove), bulk text import (`cm:`/`type:id`/bare-token, with unresolved-line reporting), rename + immutable toggle, and "Import to Live" to load a playlist into the CyTube queue. A new stateless `POST /admin/playlists/parse-text` endpoint exposes the existing text parser so parsed items merge into the editor and persist via the existing `PUT /{id}/items`.
12
+ - **Admin Schedules UI.** List of scheduled fires with playlist names, local fire times, lock window and status; create/edit/delete with `fire_at` (datetime-local → UTC), `pre_fire_lock_minutes`, `is_recurring`/`rrule`, and active toggle; "Fire Now"; and an active-schedule banner with "Clear Active".
13
+ - **Admin Queue Management UI.** Live `queue_shadow` table (auto-refreshing) with pay/scheduled metadata, ETA, paid-by and Z cost; remove (auto-refund), jump, an admin add-item modal (catalog search + placement mode), and the catalog sync log with a "Sync Now" trigger.
14
+ - **Upcoming-schedule announcement on the Queue page.** A public `GET /queue/next-schedule` feeds a banner with the next scheduled playlist, its fire time, and a live countdown (noting when pay-to-play is closed).
15
+ - Shared admin CSS for section headers, forms, modals, badges, the playlist editor list, drag-reorder, and catalog-add results.
16
+
17
+ ### Changed
18
+
19
+ - **Specific pre-fire-lock messaging.** Submitting during a lock window now returns `Pay-to-play is closed: "[event]" starts in N min.` instead of a generic locked error (surfaced in the existing toast).
20
+
21
+ ## [0.7.5] - 2026-06-08
22
+
23
+ ### Added
24
+
25
+ - **Hidden categories/tags with an admin reveal toggle.** Items in the categories `Z Channel Promos`, `Z Event Movies`, `Weekday Z Promos` and the tags `grindhousebumper`, `commercialsforbumpers`, `bumpers`, `channelz`, `grindhousetrailer`, `publicaccess`, `religioustv` are now excluded from the catalog browse/search results and from the category/tag facet dropdowns. Admins (rank ≥ 3) see a notice — "Certain items are hidden from results. Show hidden items?" — that toggles them back into view via `?show_hidden=1` (ignored for non-admins).
26
+ - **Modern, readability-focused typography.** The UI now loads Inter (body/controls) and Sora (headings) from Google Fonts, with antialiasing and `optimizeLegibility` enabled.
27
+
28
+ ### Removed
29
+
30
+ - **"Play Next" buttons** are no longer shown on the catalog browse cards or the item detail page (the underlying play-next queue mechanism is unchanged).
31
+
7
32
  ## [0.7.4] - 2026-06-08
8
33
 
9
34
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.7.4
3
+ Version: 0.8.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,47 @@ def _slugify(text: str) -> str:
12
12
  return s.strip("-") or "untitled"
13
13
 
14
14
 
15
+ # Categories and tags whose items are hidden from the public catalog (dropdowns
16
+ # and search results). Admins can opt to reveal them. Matched by exact name.
17
+ HIDDEN_CATEGORY_NAMES = [
18
+ "Z Channel Promos",
19
+ "Z Event Movies",
20
+ "Weekday Z Promos",
21
+ ]
22
+ HIDDEN_TAG_NAMES = [
23
+ "grindhousebumper",
24
+ "commercialsforbumpers",
25
+ "bumpers",
26
+ "channelz",
27
+ "grindhousetrailer",
28
+ "publicaccess",
29
+ "religioustv",
30
+ ]
31
+
32
+
33
+ def _hidden_exclusion(alias: str = "c") -> tuple[str, list]:
34
+ """SQL fragment (+ params) excluding items in hidden categories/tags.
35
+
36
+ The fragment is prefixed with ``AND`` so it can be appended to an existing
37
+ WHERE clause that references the catalog row under ``alias``.
38
+ """
39
+ cat_ph = ",".join("?" * len(HIDDEN_CATEGORY_NAMES))
40
+ tag_ph = ",".join("?" * len(HIDDEN_TAG_NAMES))
41
+ sql = f"""
42
+ AND {alias}.friendly_token NOT IN (
43
+ SELECT cc.friendly_token FROM catalog_categories cc
44
+ JOIN categories cat ON cc.category_id = cat.id
45
+ WHERE cat.name IN ({cat_ph})
46
+ )
47
+ AND {alias}.friendly_token NOT IN (
48
+ SELECT ct.friendly_token FROM catalog_tags ct
49
+ JOIN tags t ON ct.tag_id = t.id
50
+ WHERE t.name IN ({tag_ph})
51
+ )
52
+ """
53
+ return sql, [*HIDDEN_CATEGORY_NAMES, *HIDDEN_TAG_NAMES]
54
+
55
+
15
56
  MIGRATIONS = [
16
57
  # v1: Migration tracking table
17
58
  """
@@ -245,7 +286,7 @@ class Database:
245
286
 
246
287
  # --- Catalog ---
247
288
 
248
- async def browse(self, *, category: str | None = None, tag: str | None = None, page: int = 1, per_page: int = 24) -> list[dict]:
289
+ async def browse(self, *, category: str | None = None, tag: str | None = None, page: int = 1, per_page: int = 24, show_hidden: bool = False) -> list[dict]:
249
290
  query = """
250
291
  SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.thumbnail_url, c.manifest_url
251
292
  FROM catalog c
@@ -256,6 +297,10 @@ class Database:
256
297
  )
257
298
  """
258
299
  params: list = []
300
+ if not show_hidden:
301
+ excl_sql, excl_params = _hidden_exclusion("c")
302
+ query += excl_sql
303
+ params.extend(excl_params)
259
304
  if category:
260
305
  query += """
261
306
  AND c.friendly_token IN (
@@ -296,7 +341,7 @@ class Database:
296
341
  params.extend([per_page, (page - 1) * per_page])
297
342
  return await self._fetch_all(query, params)
298
343
 
299
- async def browse_count(self, *, category: str | None = None, tag: str | None = None) -> int:
344
+ async def browse_count(self, *, category: str | None = None, tag: str | None = None, show_hidden: bool = False) -> int:
300
345
  query = """
301
346
  SELECT COUNT(*) as cnt FROM catalog c
302
347
  WHERE c.friendly_token NOT IN (
@@ -306,6 +351,10 @@ class Database:
306
351
  )
307
352
  """
308
353
  params: list = []
354
+ if not show_hidden:
355
+ excl_sql, excl_params = _hidden_exclusion("c")
356
+ query += excl_sql
357
+ params.extend(excl_params)
309
358
  if category:
310
359
  query += """
311
360
  AND c.friendly_token IN (
@@ -327,7 +376,7 @@ class Database:
327
376
  row = await self._fetch_one(query, params)
328
377
  return row["cnt"] if row else 0
329
378
 
330
- async def search(self, query_text: str, *, page: int = 1, per_page: int = 24) -> list[dict]:
379
+ async def search(self, query_text: str, *, page: int = 1, per_page: int = 24, show_hidden: bool = False) -> list[dict]:
331
380
  sql = """
332
381
  SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.thumbnail_url, c.manifest_url,
333
382
  rank AS relevance
@@ -339,12 +388,20 @@ class Database:
339
388
  JOIN saved_playlists sp ON spi.playlist_id = sp.id
340
389
  WHERE sp.is_immutable = 1 AND spi.media_type = 'cm'
341
390
  )
391
+ """
392
+ params: list = [query_text]
393
+ if not show_hidden:
394
+ excl_sql, excl_params = _hidden_exclusion("c")
395
+ sql += excl_sql
396
+ params.extend(excl_params)
397
+ sql += """
342
398
  ORDER BY rank
343
399
  LIMIT ? OFFSET ?
344
400
  """
345
- return await self._fetch_all(sql, [query_text, per_page, (page - 1) * per_page])
401
+ params.extend([per_page, (page - 1) * per_page])
402
+ return await self._fetch_all(sql, params)
346
403
 
347
- async def search_count(self, query_text: str) -> int:
404
+ async def search_count(self, query_text: str, *, show_hidden: bool = False) -> int:
348
405
  sql = """
349
406
  SELECT COUNT(*) as cnt
350
407
  FROM catalog_fts fts
@@ -356,7 +413,12 @@ class Database:
356
413
  WHERE sp.is_immutable = 1 AND spi.media_type = 'cm'
357
414
  )
358
415
  """
359
- row = await self._fetch_one(sql, [query_text])
416
+ params: list = [query_text]
417
+ if not show_hidden:
418
+ excl_sql, excl_params = _hidden_exclusion("c")
419
+ sql += excl_sql
420
+ params.extend(excl_params)
421
+ row = await self._fetch_one(sql, params)
360
422
  return row["cnt"] if row else 0
361
423
 
362
424
  async def get_item(self, friendly_token: str) -> dict | None:
@@ -438,31 +500,43 @@ class Database:
438
500
  row = await self._fetch_one(sql, [friendly_token])
439
501
  return row is not None
440
502
 
441
- async def get_categories(self) -> list[dict]:
503
+ async def get_categories(self, *, show_hidden: bool = False) -> list[dict]:
442
504
  """Distinct categories that have at least one catalog item, for facets."""
443
- return await self._fetch_all(
444
- """
505
+ sql = """
445
506
  SELECT c.id, c.name, c.slug, COUNT(cc.friendly_token) AS cnt
446
507
  FROM categories c
447
508
  JOIN catalog_categories cc ON cc.category_id = c.id
509
+ """
510
+ params: list = []
511
+ if not show_hidden:
512
+ ph = ",".join("?" * len(HIDDEN_CATEGORY_NAMES))
513
+ sql += f" WHERE c.name NOT IN ({ph})"
514
+ params.extend(HIDDEN_CATEGORY_NAMES)
515
+ sql += """
448
516
  GROUP BY c.id, c.name, c.slug
449
517
  ORDER BY c.name
450
- """
451
- )
518
+ """
519
+ return await self._fetch_all(sql, params)
452
520
 
453
- async def get_tags(self, *, limit: int = 100) -> list[dict]:
521
+ async def get_tags(self, *, limit: int = 100, show_hidden: bool = False) -> list[dict]:
454
522
  """Most-used tags that have at least one catalog item, for facets."""
455
- return await self._fetch_all(
456
- """
523
+ sql = """
457
524
  SELECT t.id, t.name, COUNT(ct.friendly_token) AS cnt
458
525
  FROM tags t
459
526
  JOIN catalog_tags ct ON ct.tag_id = t.id
527
+ """
528
+ params: list = []
529
+ if not show_hidden:
530
+ ph = ",".join("?" * len(HIDDEN_TAG_NAMES))
531
+ sql += f" WHERE t.name NOT IN ({ph})"
532
+ params.extend(HIDDEN_TAG_NAMES)
533
+ sql += """
460
534
  GROUP BY t.id, t.name
461
535
  ORDER BY cnt DESC, t.name ASC
462
536
  LIMIT ?
463
- """,
464
- [limit],
465
- )
537
+ """
538
+ params.append(limit)
539
+ return await self._fetch_all(sql, params)
466
540
 
467
541
  async def upsert_category(self, name: str) -> int:
468
542
  """Insert a category by name (deriving a unique slug) and return its id."""
@@ -807,6 +881,21 @@ class Database:
807
881
  """)
808
882
  return row is not None
809
883
 
884
+ async def get_active_pre_fire_lock(self) -> dict | None:
885
+ """Return the schedule whose pre-fire lock window is currently active.
886
+
887
+ Used to give users a specific "pay-to-play closes before [event]"
888
+ message instead of a generic locked error.
889
+ """
890
+ return await self._fetch_one("""
891
+ SELECT * FROM playlist_schedules
892
+ WHERE is_active = 1
893
+ AND datetime(fire_at, '-' || pre_fire_lock_minutes || ' minutes') <= datetime('now')
894
+ AND fire_at > datetime('now')
895
+ ORDER BY fire_at
896
+ LIMIT 1
897
+ """)
898
+
810
899
  async def get_next_schedule(self) -> dict | None:
811
900
  return await self._fetch_one(
812
901
  "SELECT * FROM playlist_schedules WHERE is_active=1 AND fire_at > datetime('now') ORDER BY fire_at LIMIT 1"
@@ -96,3 +96,18 @@ async def import_to_live(request: Request, playlist_id: int, user: dict = Depend
96
96
  )
97
97
  result = await importer.import_playlist(playlist_id)
98
98
  return result
99
+
100
+
101
+ @router.post("/parse-text")
102
+ async def parse_text(request: Request, user: dict = Depends(require_admin)):
103
+ """Parse the plain-text playlist import format into resolved items.
104
+
105
+ Stateless: returns {items, errors} for the editor to merge into its working
106
+ list. Persistence happens via PUT /{id}/items when the admin saves.
107
+ """
108
+ from ..playlists.importer import import_playlist_text
109
+
110
+ body = await request.json()
111
+ text = body.get("text", "")
112
+ db = request.app.state.db
113
+ return await import_playlist_text(db, text)
@@ -7,23 +7,25 @@ router = APIRouter(prefix="/catalog", tags=["catalog"])
7
7
 
8
8
  @router.get("/browse")
9
9
  async def browse(request: Request, category: str | None = None, tag: str | None = None,
10
- page: int = 1, user: dict = Depends(get_current_user)):
10
+ page: int = 1, show_hidden: int = 0, user: dict = Depends(get_current_user)):
11
11
  """Browse catalog with optional category/tag filter."""
12
12
  db = request.app.state.db
13
- items = await db.browse(category=category, tag=tag, page=page)
14
- categories = await db.get_categories()
15
- tags = await db.get_tags()
13
+ show_hidden = bool(show_hidden) and (user.get("rank") or 0) >= 3
14
+ items = await db.browse(category=category, tag=tag, page=page, show_hidden=show_hidden)
15
+ categories = await db.get_categories(show_hidden=show_hidden)
16
+ tags = await db.get_tags(show_hidden=show_hidden)
16
17
  return {"items": items, "categories": categories, "tags": tags, "page": page}
17
18
 
18
19
 
19
20
  @router.get("/search")
20
- async def search(request: Request, q: str = "", page: int = 1,
21
+ async def search(request: Request, q: str = "", page: int = 1, show_hidden: int = 0,
21
22
  user: dict = Depends(get_current_user)):
22
23
  """Full-text search of catalog."""
23
24
  if not q.strip():
24
25
  raise HTTPException(400, "Query required")
25
26
  db = request.app.state.db
26
- items = await db.search(q, page=page)
27
+ show_hidden = bool(show_hidden) and (user.get("rank") or 0) >= 3
28
+ items = await db.search(q, page=page, show_hidden=show_hidden)
27
29
  return {"items": items, "query": q, "page": page}
28
30
 
29
31
 
@@ -41,16 +41,19 @@ async def login_page(request: Request):
41
41
 
42
42
  @router.get("/catalog/browse", response_class=HTMLResponse)
43
43
  async def catalog_browse_page(request: Request, category: str | None = None,
44
- tag: str | None = None, page: int = 1):
44
+ tag: str | None = None, page: int = 1,
45
+ show_hidden: int = 0):
45
46
  user = _get_user_or_none(request)
46
47
  if not user:
47
48
  return RedirectResponse("/auth/login")
48
49
  db = request.app.state.db
49
- items = await db.browse(category=category, tag=tag, page=page)
50
- total = await db.browse_count(category=category, tag=tag)
50
+ is_admin = (user.get("rank") or 0) >= 3
51
+ show_hidden = bool(show_hidden) and is_admin
52
+ items = await db.browse(category=category, tag=tag, page=page, show_hidden=show_hidden)
53
+ total = await db.browse_count(category=category, tag=tag, show_hidden=show_hidden)
51
54
  total_pages = max(1, (total + 23) // 24)
52
- categories = await db.get_categories()
53
- tags = await db.get_tags()
55
+ categories = await db.get_categories(show_hidden=show_hidden)
56
+ tags = await db.get_tags(show_hidden=show_hidden)
54
57
  return templates.TemplateResponse(request, "catalog/browse.html", {
55
58
  "user": user,
56
59
  "items": items,
@@ -61,22 +64,27 @@ async def catalog_browse_page(request: Request, category: str | None = None,
61
64
  "active_category": category,
62
65
  "active_tag": tag,
63
66
  "query": None,
67
+ "is_admin": is_admin,
68
+ "show_hidden": show_hidden,
64
69
  })
65
70
 
66
71
 
67
72
  @router.get("/catalog/search", response_class=HTMLResponse)
68
- async def catalog_search_page(request: Request, q: str = "", page: int = 1):
73
+ async def catalog_search_page(request: Request, q: str = "", page: int = 1,
74
+ show_hidden: int = 0):
69
75
  user = _get_user_or_none(request)
70
76
  if not user:
71
77
  return RedirectResponse("/auth/login")
72
78
  if not q.strip():
73
79
  return RedirectResponse("/catalog/browse")
74
80
  db = request.app.state.db
75
- items = await db.search(q, page=page)
76
- total = await db.search_count(q)
81
+ is_admin = (user.get("rank") or 0) >= 3
82
+ show_hidden = bool(show_hidden) and is_admin
83
+ items = await db.search(q, page=page, show_hidden=show_hidden)
84
+ total = await db.search_count(q, show_hidden=show_hidden)
77
85
  total_pages = max(1, (total + 23) // 24)
78
- categories = await db.get_categories()
79
- tags = await db.get_tags()
86
+ categories = await db.get_categories(show_hidden=show_hidden)
87
+ tags = await db.get_tags(show_hidden=show_hidden)
80
88
  return templates.TemplateResponse(request, "catalog/browse.html", {
81
89
  "user": user,
82
90
  "items": items,
@@ -87,6 +95,8 @@ async def catalog_search_page(request: Request, q: str = "", page: int = 1):
87
95
  "active_category": None,
88
96
  "active_tag": None,
89
97
  "query": q,
98
+ "is_admin": is_admin,
99
+ "show_hidden": show_hidden,
90
100
  })
91
101
 
92
102
 
@@ -1,4 +1,5 @@
1
1
  from fastapi import APIRouter, Request, Depends, HTTPException
2
+ from datetime import datetime, UTC
2
3
 
3
4
  from ..auth.session import get_current_user
4
5
  from ..queue.ordering import insert_pay_queue, insert_pay_playnext
@@ -6,6 +7,25 @@ from ..queue.ordering import insert_pay_queue, insert_pay_playnext
6
7
  router = APIRouter(prefix="/queue", tags=["queue"])
7
8
 
8
9
 
10
+ async def _pre_fire_lock_detail(db) -> str:
11
+ """Build a specific 'pay-to-play closes before [event]' message.
12
+
13
+ Falls back to a generic message if the locking schedule can't be read.
14
+ """
15
+ lock = await db.get_active_pre_fire_lock()
16
+ if not lock:
17
+ return "Queue is locked: a scheduled playlist is firing soon."
18
+ label = lock.get("label") or "a scheduled event"
19
+ try:
20
+ fire_at = datetime.fromisoformat(lock["fire_at"])
21
+ if fire_at.tzinfo is None:
22
+ fire_at = fire_at.replace(tzinfo=UTC)
23
+ minutes = max(0, round((fire_at - datetime.now(UTC)).total_seconds() / 60))
24
+ return f'Pay-to-play is closed: "{label}" starts in {minutes} min. Try again after the event.'
25
+ except Exception:
26
+ return f'Pay-to-play is closed ahead of "{label}". Try again after the event.'
27
+
28
+
9
29
  @router.get("/state")
10
30
  async def get_queue_state(request: Request, user: dict = Depends(get_current_user)):
11
31
  """Get current queue state."""
@@ -30,7 +50,7 @@ async def add_to_queue(request: Request, user: dict = Depends(get_current_user))
30
50
 
31
51
  # Check pre-fire lock
32
52
  if await db.is_pre_fire_lock_active():
33
- raise HTTPException(423, "Queue is locked: scheduled playlist firing soon")
53
+ raise HTTPException(423, await _pre_fire_lock_detail(db))
34
54
 
35
55
  # Look up catalog item
36
56
  item = await db.get_item(friendly_token)
@@ -86,7 +106,7 @@ async def play_next(request: Request, user: dict = Depends(get_current_user)):
86
106
 
87
107
  # Check pre-fire lock
88
108
  if await db.is_pre_fire_lock_active():
89
- raise HTTPException(423, "Queue is locked: scheduled playlist firing soon")
109
+ raise HTTPException(423, await _pre_fire_lock_detail(db))
90
110
 
91
111
  # Look up catalog item
92
112
  item = await db.get_item(friendly_token)
@@ -187,3 +207,21 @@ async def queue_history(request: Request, user: dict = Depends(get_current_user)
187
207
  db = request.app.state.db
188
208
  history = await db.get_user_queue_history(user["username"])
189
209
  return {"items": history}
210
+
211
+
212
+ @router.get("/next-schedule")
213
+ async def next_schedule(request: Request, user: dict = Depends(get_current_user)):
214
+ """Public-facing info about the next scheduled playlist (for the queue page
215
+ announcement banner). Returns {} when nothing is scheduled.
216
+ """
217
+ db = request.app.state.db
218
+ sched = await db.get_next_schedule()
219
+ if not sched:
220
+ return {}
221
+ lock_active = await db.is_pre_fire_lock_active()
222
+ return {
223
+ "label": sched.get("label"),
224
+ "fire_at": sched.get("fire_at"),
225
+ "pre_fire_lock_minutes": sched.get("pre_fire_lock_minutes"),
226
+ "lock_active": lock_active,
227
+ }
@@ -16,6 +16,8 @@
16
16
  --radius: 8px;
17
17
  --shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
18
18
  --nav-height: 4rem;
19
+ --font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
20
+ --font-heading: 'Sora', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
19
21
  }
20
22
 
21
23
  * {
@@ -30,11 +32,20 @@ html {
30
32
  }
31
33
 
32
34
  body {
33
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
35
+ font-family: var(--font-body);
34
36
  background: var(--bg-primary);
35
37
  color: var(--text-primary);
36
38
  line-height: 1.6;
37
39
  min-height: 100vh;
40
+ -webkit-font-smoothing: antialiased;
41
+ -moz-osx-font-smoothing: grayscale;
42
+ text-rendering: optimizeLegibility;
43
+ }
44
+
45
+ h1, h2, h3, h4, h5, h6,
46
+ .nav-brand a {
47
+ font-family: var(--font-heading);
48
+ letter-spacing: -0.01em;
38
49
  }
39
50
 
40
51
  a {
@@ -50,7 +61,7 @@ a:hover {
50
61
  display: flex;
51
62
  justify-content: space-between;
52
63
  align-items: center;
53
- padding: 0.75rem 2rem;
64
+ padding: 0.35rem 2rem;
54
65
  background: var(--bg-secondary);
55
66
  border-bottom: 1px solid var(--border);
56
67
  position: sticky;
@@ -127,6 +138,18 @@ a:hover {
127
138
  align-items: center;
128
139
  flex-wrap: wrap;
129
140
  }
141
+ .admin-hidden-notice {
142
+ margin-top: 0.85rem;
143
+ font-size: 0.85rem;
144
+ color: var(--text-secondary);
145
+ display: flex;
146
+ gap: 0.5rem;
147
+ align-items: center;
148
+ flex-wrap: wrap;
149
+ }
150
+ .admin-hidden-notice a {
151
+ font-weight: 600;
152
+ }
130
153
  .search-form {
131
154
  display: flex;
132
155
  gap: 0.5rem;
@@ -328,10 +351,10 @@ a:hover {
328
351
  /* Title spans the full width of the card. */
329
352
  .np-title {
330
353
  margin: 0;
331
- font-size: 1.4rem;
332
- line-height: 1.25;
354
+ font-size: 1rem;
355
+ line-height: 1.1;
333
356
  overflow-wrap: anywhere;
334
- padding-bottom: 0.75rem;
357
+ padding-bottom: 0.5rem;
335
358
  border-bottom: 1px solid var(--border);
336
359
  }
337
360
  /* Image + time display sit side by side. */
@@ -499,10 +522,10 @@ a:hover {
499
522
  }
500
523
  /* Description + category/tag chips sit below the image / time row. */
501
524
  .np-description {
502
- font-size: 0.9rem;
503
- line-height: 1.5;
525
+ font-size: 0.7rem;
526
+ line-height: 1.1;
504
527
  color: var(--text-secondary);
505
- padding-top: 0.75rem;
528
+ padding-top: 0.5rem;
506
529
  border-top: 1px solid var(--border);
507
530
  white-space: pre-line;
508
531
  overflow-wrap: anywhere;
@@ -959,3 +982,134 @@ a.np-chip {
959
982
  font-size: 0.85rem;
960
983
  }
961
984
 
985
+ /* ===== Admin management UIs (playlists / schedules / queue) ===== */
986
+ .section-head {
987
+ display: flex;
988
+ align-items: center;
989
+ justify-content: space-between;
990
+ gap: 1rem;
991
+ flex-wrap: wrap;
992
+ margin-bottom: 1rem;
993
+ }
994
+ .section-head h2 { margin-bottom: 0; }
995
+ .btn-group {
996
+ display: flex;
997
+ gap: 0.5rem;
998
+ flex-wrap: wrap;
999
+ }
1000
+ .btn-xs {
1001
+ padding: 0.15rem 0.45rem;
1002
+ font-size: 0.72rem;
1003
+ border: 1px solid var(--border);
1004
+ border-radius: 4px;
1005
+ background: var(--bg-card);
1006
+ color: var(--text-primary);
1007
+ cursor: pointer;
1008
+ }
1009
+ .btn-xs:hover { background: var(--bg-hover); }
1010
+ .btn-xs:disabled { opacity: 0.4; cursor: not-allowed; }
1011
+ .row-actions {
1012
+ display: flex;
1013
+ gap: 0.4rem;
1014
+ justify-content: flex-end;
1015
+ flex-wrap: wrap;
1016
+ }
1017
+ .muted { color: var(--text-secondary); font-size: 0.85rem; }
1018
+
1019
+ /* Badges */
1020
+ .badge {
1021
+ display: inline-block;
1022
+ font-size: 0.7rem;
1023
+ padding: 0.1rem 0.45rem;
1024
+ border-radius: 999px;
1025
+ background: var(--bg-card);
1026
+ border: 1px solid var(--border);
1027
+ color: var(--text-secondary);
1028
+ }
1029
+ .badge-warn { background: rgba(253, 203, 110, 0.15); border-color: var(--warning); color: var(--warning); }
1030
+ .badge-accent { background: rgba(108, 92, 231, 0.2); border-color: var(--accent); color: var(--text-primary); }
1031
+
1032
+ /* Modal form fields */
1033
+ .field {
1034
+ display: flex;
1035
+ flex-direction: column;
1036
+ gap: 0.3rem;
1037
+ margin-bottom: 0.85rem;
1038
+ }
1039
+ .field > span { font-size: 0.8rem; color: var(--text-secondary); }
1040
+ .field input, .field select, .field textarea {
1041
+ padding: 0.5rem 0.65rem;
1042
+ border: 1px solid var(--border);
1043
+ border-radius: var(--radius);
1044
+ background: var(--bg-secondary);
1045
+ color: var(--text-primary);
1046
+ font: inherit;
1047
+ width: 100%;
1048
+ }
1049
+ .check {
1050
+ display: flex;
1051
+ align-items: center;
1052
+ gap: 0.5rem;
1053
+ font-size: 0.85rem;
1054
+ margin-bottom: 0.85rem;
1055
+ cursor: pointer;
1056
+ }
1057
+
1058
+ /* Playlist editor */
1059
+ .editor-grid {
1060
+ display: grid;
1061
+ grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
1062
+ gap: 2rem;
1063
+ }
1064
+ @media (max-width: 800px) { .editor-grid { grid-template-columns: 1fr; } }
1065
+ .editor-list {
1066
+ list-style: none;
1067
+ display: flex;
1068
+ flex-direction: column;
1069
+ gap: 0.35rem;
1070
+ margin: 0;
1071
+ padding: 0;
1072
+ }
1073
+ .editor-row {
1074
+ display: grid;
1075
+ grid-template-columns: 1.2rem 1.6rem minmax(0, 1fr) auto auto auto;
1076
+ align-items: center;
1077
+ gap: 0.6rem;
1078
+ padding: 0.45rem 0.6rem;
1079
+ background: var(--bg-card);
1080
+ border-radius: var(--radius);
1081
+ border: 1px solid transparent;
1082
+ }
1083
+ .editor-row:hover { border-color: var(--border); }
1084
+ .drag-handle { cursor: grab; color: var(--text-secondary); user-select: none; }
1085
+ .editor-row .pos { color: var(--text-secondary); font-size: 0.8rem; }
1086
+ .ed-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1087
+ .ed-type, .ed-dur { font-size: 0.78rem; color: var(--text-secondary); }
1088
+ .ed-move { display: flex; gap: 0.25rem; }
1089
+
1090
+ /* Catalog add results */
1091
+ .cat-results { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.6rem; max-height: 340px; overflow-y: auto; }
1092
+ .cat-result {
1093
+ display: flex;
1094
+ align-items: center;
1095
+ justify-content: space-between;
1096
+ gap: 0.6rem;
1097
+ padding: 0.4rem 0.6rem;
1098
+ background: var(--bg-card);
1099
+ border-radius: var(--radius);
1100
+ }
1101
+ .import-text {
1102
+ width: 100%;
1103
+ padding: 0.5rem;
1104
+ border: 1px solid var(--border);
1105
+ border-radius: var(--radius);
1106
+ background: var(--bg-secondary);
1107
+ color: var(--text-primary);
1108
+ font-family: monospace;
1109
+ font-size: 0.85rem;
1110
+ margin-bottom: 0.5rem;
1111
+ resize: vertical;
1112
+ }
1113
+ .import-errors { font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.5rem; }
1114
+ .import-errors code { color: var(--warning); }
1115
+