kryten-webqueue 0.7.5__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.5 → kryten_webqueue-0.8.0}/CHANGELOG.md +14 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/db.py +15 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_playlists.py +15 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/queue.py +40 -2
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/static/css/main.css +138 -7
- 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.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/queue/index.html +40 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/pyproject.toml +1 -1
- kryten_webqueue-0.7.5/kryten_webqueue/templates/admin/playlists.html +0 -14
- kryten_webqueue-0.7.5/kryten_webqueue/templates/admin/queue_mgmt.html +0 -14
- kryten_webqueue-0.7.5/kryten_webqueue/templates/admin/schedules.html +0 -14
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/.gitignore +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/README.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/config.example.json +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/ws/manager.py +0 -0
|
@@ -4,6 +4,20 @@ 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
|
+
|
|
7
21
|
## [0.7.5] - 2026-06-08
|
|
8
22
|
|
|
9
23
|
### Added
|
|
@@ -881,6 +881,21 @@ class Database:
|
|
|
881
881
|
""")
|
|
882
882
|
return row is not None
|
|
883
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
|
+
|
|
884
899
|
async def get_next_schedule(self) -> dict | None:
|
|
885
900
|
return await self._fetch_one(
|
|
886
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)
|
|
@@ -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
|
+
}
|
|
@@ -61,7 +61,7 @@ a:hover {
|
|
|
61
61
|
display: flex;
|
|
62
62
|
justify-content: space-between;
|
|
63
63
|
align-items: center;
|
|
64
|
-
padding: 0.
|
|
64
|
+
padding: 0.35rem 2rem;
|
|
65
65
|
background: var(--bg-secondary);
|
|
66
66
|
border-bottom: 1px solid var(--border);
|
|
67
67
|
position: sticky;
|
|
@@ -351,10 +351,10 @@ a:hover {
|
|
|
351
351
|
/* Title spans the full width of the card. */
|
|
352
352
|
.np-title {
|
|
353
353
|
margin: 0;
|
|
354
|
-
font-size:
|
|
355
|
-
line-height: 1.
|
|
354
|
+
font-size: 1rem;
|
|
355
|
+
line-height: 1.1;
|
|
356
356
|
overflow-wrap: anywhere;
|
|
357
|
-
padding-bottom: 0.
|
|
357
|
+
padding-bottom: 0.5rem;
|
|
358
358
|
border-bottom: 1px solid var(--border);
|
|
359
359
|
}
|
|
360
360
|
/* Image + time display sit side by side. */
|
|
@@ -522,10 +522,10 @@ a:hover {
|
|
|
522
522
|
}
|
|
523
523
|
/* Description + category/tag chips sit below the image / time row. */
|
|
524
524
|
.np-description {
|
|
525
|
-
font-size: 0.
|
|
526
|
-
line-height: 1.
|
|
525
|
+
font-size: 0.7rem;
|
|
526
|
+
line-height: 1.1;
|
|
527
527
|
color: var(--text-secondary);
|
|
528
|
-
padding-top: 0.
|
|
528
|
+
padding-top: 0.5rem;
|
|
529
529
|
border-top: 1px solid var(--border);
|
|
530
530
|
white-space: pre-line;
|
|
531
531
|
overflow-wrap: anywhere;
|
|
@@ -982,3 +982,134 @@ a.np-chip {
|
|
|
982
982
|
font-size: 0.85rem;
|
|
983
983
|
}
|
|
984
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
|
+
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}Playlists - Admin{% endblock %}
|
|
3
|
+
{% block body_class %}admin-page{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<div class="admin-dashboard">
|
|
7
|
+
<h1>Playlists</h1>
|
|
8
|
+
<p><a href="/admin">← Back to Admin</a></p>
|
|
9
|
+
|
|
10
|
+
<!-- LIST VIEW -->
|
|
11
|
+
<div id="list-view">
|
|
12
|
+
<div class="admin-section">
|
|
13
|
+
<div class="section-head">
|
|
14
|
+
<h2>Saved Playlists</h2>
|
|
15
|
+
<button class="btn btn-primary" onclick="showCreateModal()">+ New Playlist</button>
|
|
16
|
+
</div>
|
|
17
|
+
<div id="playlists-list">Loading…</div>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<!-- EDITOR VIEW -->
|
|
22
|
+
<div id="editor-view" class="hidden">
|
|
23
|
+
<div class="admin-section">
|
|
24
|
+
<div class="section-head">
|
|
25
|
+
<h2 id="editor-title">Edit Playlist</h2>
|
|
26
|
+
<div class="btn-group">
|
|
27
|
+
<button class="btn btn-secondary" onclick="closeEditor()">← Back</button>
|
|
28
|
+
<button class="btn" onclick="editMeta()">Rename</button>
|
|
29
|
+
<button class="btn btn-primary" onclick="saveItems()">Save Items</button>
|
|
30
|
+
<button class="btn" onclick="importToLive()" title="Load this playlist into the live CyTube queue">Import to Live</button>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
<p id="editor-meta" class="muted"></p>
|
|
34
|
+
|
|
35
|
+
<div class="editor-grid">
|
|
36
|
+
<!-- Working item list -->
|
|
37
|
+
<div>
|
|
38
|
+
<h3>Items (<span id="item-count">0</span>)</h3>
|
|
39
|
+
<ul id="editor-items" class="editor-list"></ul>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<!-- Add panel -->
|
|
43
|
+
<div>
|
|
44
|
+
<h3>Add from Catalog</h3>
|
|
45
|
+
<div class="search-form">
|
|
46
|
+
<input type="text" id="cat-search" placeholder="Search catalog…" onkeydown="if(event.key==='Enter')catalogSearch()">
|
|
47
|
+
<button class="btn btn-sm" onclick="catalogSearch()">Search</button>
|
|
48
|
+
</div>
|
|
49
|
+
<div id="cat-results" class="cat-results"></div>
|
|
50
|
+
|
|
51
|
+
<h3 style="margin-top:1.5rem;">Bulk Text Import</h3>
|
|
52
|
+
<p class="muted" style="font-size:0.8rem;">One token per line. <code>cm:token</code>, <code>yt:id</code>, or a bare catalog token. <code>#</code> comments allowed.</p>
|
|
53
|
+
<textarea id="import-text" class="import-text" rows="5" placeholder="abc123def yt:dQw4w9WgXcQ"></textarea>
|
|
54
|
+
<button class="btn btn-sm" onclick="parseImport()">Append Parsed Items</button>
|
|
55
|
+
<div id="import-errors" class="import-errors"></div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
{% endblock %}
|
|
62
|
+
|
|
63
|
+
{% block scripts %}
|
|
64
|
+
<script>
|
|
65
|
+
let editorId = null;
|
|
66
|
+
let editorItems = []; // working list: {media_type, media_id, title, duration_sec}
|
|
67
|
+
let editorImmutable = false;
|
|
68
|
+
|
|
69
|
+
function fmtDur(sec) {
|
|
70
|
+
sec = Math.floor(sec || 0);
|
|
71
|
+
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
|
72
|
+
if (h > 0) return `${h}:${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
|
|
73
|
+
return `${m}:${s.toString().padStart(2,'0')}`;
|
|
74
|
+
}
|
|
75
|
+
function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str == null ? '' : str; return d.innerHTML; }
|
|
76
|
+
|
|
77
|
+
// ---------- LIST ----------
|
|
78
|
+
async function loadPlaylists() {
|
|
79
|
+
const resp = await fetch('/admin/playlists/');
|
|
80
|
+
const el = document.getElementById('playlists-list');
|
|
81
|
+
if (!resp.ok) { el.innerHTML = '<p class="empty-state">Failed to load.</p>'; return; }
|
|
82
|
+
const rows = await resp.json();
|
|
83
|
+
if (!rows.length) { el.innerHTML = '<p class="empty-state">No playlists yet.</p>'; return; }
|
|
84
|
+
el.innerHTML = `<table class="admin-table">
|
|
85
|
+
<tr><th>Name</th><th>Reserved</th><th>Created by</th><th></th></tr>
|
|
86
|
+
${rows.map(p => `
|
|
87
|
+
<tr>
|
|
88
|
+
<td><a href="#" onclick="openEditor(${p.id});return false;">${escapeHtml(p.name)}</a>
|
|
89
|
+
${p.description ? `<div class="muted">${escapeHtml(p.description)}</div>` : ''}</td>
|
|
90
|
+
<td>${p.is_immutable ? '<span class="badge badge-warn">Immutable</span>' : '—'}</td>
|
|
91
|
+
<td>${escapeHtml(p.created_by || '')}</td>
|
|
92
|
+
<td class="row-actions">
|
|
93
|
+
<button class="btn btn-sm" onclick="openEditor(${p.id})">Edit</button>
|
|
94
|
+
<button class="btn btn-sm btn-danger" onclick="deletePlaylist(${p.id}, '${escapeHtml(p.name)}')">Delete</button>
|
|
95
|
+
</td>
|
|
96
|
+
</tr>`).join('')}
|
|
97
|
+
</table>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function showCreateModal() {
|
|
101
|
+
showModal(`
|
|
102
|
+
<h3>New Playlist</h3>
|
|
103
|
+
<label class="field"><span>Name</span><input type="text" id="pl-name"></label>
|
|
104
|
+
<label class="field"><span>Description</span><input type="text" id="pl-desc"></label>
|
|
105
|
+
<label class="check"><input type="checkbox" id="pl-immut"> Immutable (reserve items — hidden from public catalog)</label>
|
|
106
|
+
<div class="modal-actions">
|
|
107
|
+
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
|
108
|
+
<button class="btn btn-primary" onclick="createPlaylist()">Create</button>
|
|
109
|
+
</div>`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function createPlaylist() {
|
|
113
|
+
const name = document.getElementById('pl-name').value.trim();
|
|
114
|
+
if (!name) { showToast('Name required', 'error'); return; }
|
|
115
|
+
const body = {
|
|
116
|
+
name,
|
|
117
|
+
description: document.getElementById('pl-desc').value.trim() || null,
|
|
118
|
+
is_immutable: document.getElementById('pl-immut').checked,
|
|
119
|
+
};
|
|
120
|
+
const resp = await fetch('/admin/playlists/', {
|
|
121
|
+
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
|
|
122
|
+
});
|
|
123
|
+
closeModal();
|
|
124
|
+
if (resp.ok) { const d = await resp.json(); showToast('Created'); loadPlaylists(); openEditor(d.id); }
|
|
125
|
+
else showToast('Create failed', 'error');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function deletePlaylist(id, name) {
|
|
129
|
+
if (!confirm(`Delete playlist "${name}"? This cannot be undone.`)) return;
|
|
130
|
+
const resp = await fetch(`/admin/playlists/${id}`, {method: 'DELETE'});
|
|
131
|
+
showToast(resp.ok ? 'Deleted' : 'Delete failed', resp.ok ? 'success' : 'error');
|
|
132
|
+
loadPlaylists();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------- EDITOR ----------
|
|
136
|
+
async function openEditor(id) {
|
|
137
|
+
const resp = await fetch(`/admin/playlists/${id}`);
|
|
138
|
+
if (!resp.ok) { showToast('Load failed', 'error'); return; }
|
|
139
|
+
const pl = await resp.json();
|
|
140
|
+
editorId = id;
|
|
141
|
+
editorImmutable = !!pl.is_immutable;
|
|
142
|
+
editorItems = (pl.items || []).map(i => ({
|
|
143
|
+
media_type: i.media_type, media_id: i.media_id, title: i.title, duration_sec: i.duration_sec
|
|
144
|
+
}));
|
|
145
|
+
document.getElementById('editor-title').textContent = pl.name;
|
|
146
|
+
document.getElementById('editor-meta').textContent =
|
|
147
|
+
`${pl.is_immutable ? 'Immutable (reserved) · ' : ''}${pl.description || ''}`;
|
|
148
|
+
document.getElementById('list-view').classList.add('hidden');
|
|
149
|
+
document.getElementById('editor-view').classList.remove('hidden');
|
|
150
|
+
document.getElementById('cat-results').innerHTML = '';
|
|
151
|
+
document.getElementById('import-errors').innerHTML = '';
|
|
152
|
+
renderEditorItems();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function closeEditor() {
|
|
156
|
+
document.getElementById('editor-view').classList.add('hidden');
|
|
157
|
+
document.getElementById('list-view').classList.remove('hidden');
|
|
158
|
+
editorId = null; editorItems = [];
|
|
159
|
+
loadPlaylists();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function renderEditorItems() {
|
|
163
|
+
document.getElementById('item-count').textContent = editorItems.length;
|
|
164
|
+
const el = document.getElementById('editor-items');
|
|
165
|
+
if (!editorItems.length) { el.innerHTML = '<li class="muted">No items. Add from the catalog or text import.</li>'; return; }
|
|
166
|
+
el.innerHTML = editorItems.map((it, i) => `
|
|
167
|
+
<li class="editor-row" draggable="true" data-i="${i}"
|
|
168
|
+
ondragstart="dragStart(event,${i})" ondragover="event.preventDefault()" ondrop="dropOn(event,${i})">
|
|
169
|
+
<span class="drag-handle" title="Drag to reorder">⠿</span>
|
|
170
|
+
<span class="pos">${i + 1}</span>
|
|
171
|
+
<span class="ed-title">${escapeHtml(it.title || it.media_id)}</span>
|
|
172
|
+
<span class="ed-type">${escapeHtml(it.media_type)}</span>
|
|
173
|
+
<span class="ed-dur">${it.duration_sec ? fmtDur(it.duration_sec) : '—'}</span>
|
|
174
|
+
<span class="ed-move">
|
|
175
|
+
<button class="btn btn-xs" onclick="moveItem(${i},-1)" ${i===0?'disabled':''}>↑</button>
|
|
176
|
+
<button class="btn btn-xs" onclick="moveItem(${i},1)" ${i===editorItems.length-1?'disabled':''}>↓</button>
|
|
177
|
+
<button class="btn btn-xs btn-danger" onclick="removeItem(${i})">✕</button>
|
|
178
|
+
</span>
|
|
179
|
+
</li>`).join('');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function moveItem(i, dir) {
|
|
183
|
+
const j = i + dir;
|
|
184
|
+
if (j < 0 || j >= editorItems.length) return;
|
|
185
|
+
[editorItems[i], editorItems[j]] = [editorItems[j], editorItems[i]];
|
|
186
|
+
renderEditorItems();
|
|
187
|
+
}
|
|
188
|
+
function removeItem(i) { editorItems.splice(i, 1); renderEditorItems(); }
|
|
189
|
+
|
|
190
|
+
let dragSrc = null;
|
|
191
|
+
function dragStart(e, i) { dragSrc = i; e.dataTransfer.effectAllowed = 'move'; }
|
|
192
|
+
function dropOn(e, i) {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
if (dragSrc === null || dragSrc === i) return;
|
|
195
|
+
const [moved] = editorItems.splice(dragSrc, 1);
|
|
196
|
+
editorItems.splice(i, 0, moved);
|
|
197
|
+
dragSrc = null;
|
|
198
|
+
renderEditorItems();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function catalogSearch() {
|
|
202
|
+
const q = document.getElementById('cat-search').value.trim();
|
|
203
|
+
if (!q) return;
|
|
204
|
+
const resp = await fetch(`/catalog/search?q=${encodeURIComponent(q)}`);
|
|
205
|
+
const el = document.getElementById('cat-results');
|
|
206
|
+
if (!resp.ok) { el.innerHTML = '<p class="muted">Search failed.</p>'; return; }
|
|
207
|
+
const data = await resp.json();
|
|
208
|
+
const items = data.items || [];
|
|
209
|
+
if (!items.length) { el.innerHTML = '<p class="muted">No results.</p>'; return; }
|
|
210
|
+
el.innerHTML = items.map(it => `
|
|
211
|
+
<div class="cat-result">
|
|
212
|
+
<span class="ed-title">${escapeHtml(it.title)}</span>
|
|
213
|
+
<span class="ed-dur">${it.duration_sec ? fmtDur(it.duration_sec) : ''}</span>
|
|
214
|
+
<button class="btn btn-xs btn-primary"
|
|
215
|
+
onclick='addCatalogItem(${JSON.stringify(it).replace(/'/g, "'")})'>Add</button>
|
|
216
|
+
</div>`).join('');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function addCatalogItem(it) {
|
|
220
|
+
editorItems.push({
|
|
221
|
+
media_type: 'cm',
|
|
222
|
+
media_id: it.manifest_url || it.friendly_token,
|
|
223
|
+
title: it.title,
|
|
224
|
+
duration_sec: it.duration_sec,
|
|
225
|
+
});
|
|
226
|
+
renderEditorItems();
|
|
227
|
+
showToast('Added');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function parseImport() {
|
|
231
|
+
const text = document.getElementById('import-text').value;
|
|
232
|
+
if (!text.trim()) return;
|
|
233
|
+
const resp = await fetch('/admin/playlists/parse-text', {
|
|
234
|
+
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({text})
|
|
235
|
+
});
|
|
236
|
+
if (!resp.ok) { showToast('Parse failed', 'error'); return; }
|
|
237
|
+
const data = await resp.json();
|
|
238
|
+
(data.items || []).forEach(it => editorItems.push(it));
|
|
239
|
+
renderEditorItems();
|
|
240
|
+
const errEl = document.getElementById('import-errors');
|
|
241
|
+
if ((data.errors || []).length) {
|
|
242
|
+
errEl.innerHTML = `<p class="muted">${data.errors.length} unresolved:</p><ul>` +
|
|
243
|
+
data.errors.map(e => `<li>Line ${e.line}: <code>${escapeHtml(e.token)}</code> (${e.reason})</li>`).join('') + '</ul>';
|
|
244
|
+
} else { errEl.innerHTML = ''; }
|
|
245
|
+
showToast(`Appended ${(data.items || []).length} item(s)`);
|
|
246
|
+
document.getElementById('import-text').value = '';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function saveItems() {
|
|
250
|
+
if (editorId === null) return;
|
|
251
|
+
const resp = await fetch(`/admin/playlists/${editorId}/items`, {
|
|
252
|
+
method: 'PUT', headers: {'Content-Type': 'application/json'},
|
|
253
|
+
body: JSON.stringify({items: editorItems.map((it, i) => ({...it, position: i}))})
|
|
254
|
+
});
|
|
255
|
+
showToast(resp.ok ? 'Saved' : 'Save failed', resp.ok ? 'success' : 'error');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function importToLive() {
|
|
259
|
+
if (editorId === null) return;
|
|
260
|
+
if (!confirm('Load this playlist into the live CyTube queue now?')) return;
|
|
261
|
+
const resp = await fetch(`/admin/playlists/${editorId}/import`, {method: 'POST'});
|
|
262
|
+
const data = await resp.json().catch(() => ({}));
|
|
263
|
+
if (resp.ok && data.success) showToast(`Imported ${data.added} item(s)${data.errors ? `, ${data.errors} errors` : ''}`);
|
|
264
|
+
else showToast(data.error || 'Import failed', 'error');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function editMeta() {
|
|
268
|
+
showModal(`
|
|
269
|
+
<h3>Rename Playlist</h3>
|
|
270
|
+
<label class="field"><span>Name</span><input type="text" id="em-name" value="${escapeHtml(document.getElementById('editor-title').textContent)}"></label>
|
|
271
|
+
<label class="check"><input type="checkbox" id="em-immut" ${editorImmutable ? 'checked' : ''}> Immutable (reserve items)</label>
|
|
272
|
+
<div class="modal-actions">
|
|
273
|
+
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
|
274
|
+
<button class="btn btn-primary" onclick="saveMeta()">Save</button>
|
|
275
|
+
</div>`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function saveMeta() {
|
|
279
|
+
const name = document.getElementById('em-name').value.trim();
|
|
280
|
+
const is_immutable = document.getElementById('em-immut').checked;
|
|
281
|
+
if (!name) { showToast('Name required', 'error'); return; }
|
|
282
|
+
const resp = await fetch(`/admin/playlists/${editorId}`, {
|
|
283
|
+
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, is_immutable})
|
|
284
|
+
});
|
|
285
|
+
closeModal();
|
|
286
|
+
if (resp.ok) { editorImmutable = is_immutable; document.getElementById('editor-title').textContent = name; showToast('Saved'); }
|
|
287
|
+
else showToast('Save failed', 'error');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------- generic modal ----------
|
|
291
|
+
function showModal(html) {
|
|
292
|
+
closeModal();
|
|
293
|
+
const o = document.createElement('div');
|
|
294
|
+
o.className = 'modal-overlay'; o.id = 'admin-modal';
|
|
295
|
+
o.innerHTML = `<div class="modal-box">${html}</div>`;
|
|
296
|
+
o.addEventListener('click', e => { if (e.target === o) closeModal(); });
|
|
297
|
+
document.body.appendChild(o);
|
|
298
|
+
}
|
|
299
|
+
function closeModal() { const m = document.getElementById('admin-modal'); if (m) m.remove(); }
|
|
300
|
+
|
|
301
|
+
loadPlaylists();
|
|
302
|
+
</script>
|
|
303
|
+
{% endblock %}
|