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.
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/CHANGELOG.md +25 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/db.py +106 -17
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_playlists.py +15 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/catalog.py +8 -6
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/pages.py +20 -10
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/queue.py +40 -2
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/static/css/main.css +162 -8
- kryten_webqueue-0.8.0/kryten_webqueue/templates/admin/playlists.html +303 -0
- kryten_webqueue-0.8.0/kryten_webqueue/templates/admin/queue_mgmt.html +186 -0
- kryten_webqueue-0.8.0/kryten_webqueue/templates/admin/schedules.html +179 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/base.html +3 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/catalog/browse.html +10 -1
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -1
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/queue/index.html +40 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/pyproject.toml +1 -1
- kryten_webqueue-0.7.4/kryten_webqueue/templates/admin/playlists.html +0 -14
- kryten_webqueue-0.7.4/kryten_webqueue/templates/admin/queue_mgmt.html +0 -14
- kryten_webqueue-0.7.4/kryten_webqueue/templates/admin/schedules.html +0 -14
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/.gitignore +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/README.md +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/config.example.json +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.7.4 → kryten_webqueue-0.8.0}/kryten_webqueue/ws/handler.py +0 -0
- {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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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,
|
|
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,
|
|
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: -
|
|
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.
|
|
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:
|
|
332
|
-
line-height: 1.
|
|
354
|
+
font-size: 1rem;
|
|
355
|
+
line-height: 1.1;
|
|
333
356
|
overflow-wrap: anywhere;
|
|
334
|
-
padding-bottom: 0.
|
|
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.
|
|
503
|
-
line-height: 1.
|
|
525
|
+
font-size: 0.7rem;
|
|
526
|
+
line-height: 1.1;
|
|
504
527
|
color: var(--text-secondary);
|
|
505
|
-
padding-top: 0.
|
|
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
|
+
|