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.
Files changed (70) hide show
  1. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/CHANGELOG.md +14 -0
  2. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/db.py +15 -0
  4. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_playlists.py +15 -0
  5. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/queue.py +40 -2
  6. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/static/css/main.css +138 -7
  7. kryten_webqueue-0.8.0/kryten_webqueue/templates/admin/playlists.html +303 -0
  8. kryten_webqueue-0.8.0/kryten_webqueue/templates/admin/queue_mgmt.html +186 -0
  9. kryten_webqueue-0.8.0/kryten_webqueue/templates/admin/schedules.html +179 -0
  10. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/queue/index.html +40 -0
  11. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/pyproject.toml +1 -1
  12. kryten_webqueue-0.7.5/kryten_webqueue/templates/admin/playlists.html +0 -14
  13. kryten_webqueue-0.7.5/kryten_webqueue/templates/admin/queue_mgmt.html +0 -14
  14. kryten_webqueue-0.7.5/kryten_webqueue/templates/admin/schedules.html +0 -14
  15. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/.github/workflows/python-publish.yml +0 -0
  16. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/.github/workflows/release.yml +0 -0
  17. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/.gitignore +0 -0
  18. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/README.md +0 -0
  19. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/config.example.json +0 -0
  20. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/deploy/kryten-webqueue.service +0 -0
  21. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/deploy/nginx-queue.conf +0 -0
  22. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  23. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/docs/IMPL_API_GATE.md +0 -0
  24. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/docs/IMPL_ECONOMY.md +0 -0
  25. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  26. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/docs/IMPL_ROBOT.md +0 -0
  27. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/docs/PRE_PLAN_GAPS.md +0 -0
  28. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/docs/PRODUCT_PLAN.md +0 -0
  29. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/__init__.py +0 -0
  30. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/__main__.py +0 -0
  31. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  32. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/api_gate/client.py +0 -0
  33. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/app.py +0 -0
  34. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/__init__.py +0 -0
  35. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/otp.py +0 -0
  36. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  37. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/auth/session.py +0 -0
  38. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/__init__.py +0 -0
  39. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/images.py +0 -0
  40. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/catalog/sync.py +0 -0
  41. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/config.py +0 -0
  42. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/jobs/__init__.py +0 -0
  43. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/jobs/manager.py +0 -0
  44. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/__init__.py +0 -0
  45. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/fire.py +0 -0
  46. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/importer.py +0 -0
  47. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  48. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/__init__.py +0 -0
  49. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/ordering.py +0 -0
  50. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/poller.py +0 -0
  51. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/queue/shadow.py +0 -0
  52. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/__init__.py +0 -0
  53. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  54. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  55. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  56. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/auth.py +0 -0
  57. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/catalog.py +0 -0
  58. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/pages.py +0 -0
  59. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/routes/user.py +0 -0
  60. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/static/js/main.js +0 -0
  61. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/admin/index.html +0 -0
  62. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/auth/login.html +0 -0
  63. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/base.html +0 -0
  64. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  65. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  66. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  67. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  68. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/ws/__init__.py +0 -0
  69. {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.0}/kryten_webqueue/ws/handler.py +0 -0
  70. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.7.5
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
@@ -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, "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
+ }
@@ -61,7 +61,7 @@ a:hover {
61
61
  display: flex;
62
62
  justify-content: space-between;
63
63
  align-items: center;
64
- padding: 0.75rem 2rem;
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: 1.4rem;
355
- line-height: 1.25;
354
+ font-size: 1rem;
355
+ line-height: 1.1;
356
356
  overflow-wrap: anywhere;
357
- padding-bottom: 0.75rem;
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.9rem;
526
- line-height: 1.5;
525
+ font-size: 0.7rem;
526
+ line-height: 1.1;
527
527
  color: var(--text-secondary);
528
- padding-top: 0.75rem;
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">&larr; 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()">&larr; 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&#10;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, "&#39;")})'>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 %}