SalexAI commited on
Commit
7892647
·
verified ·
1 Parent(s): 2b76608

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +124 -44
app.py CHANGED
@@ -35,15 +35,25 @@ ADMIN_CONFIG_FILE = os.path.join(DATA_ROOT, "admin_config.json")
35
  os.makedirs(VIDEO_DIR, exist_ok=True)
36
 
37
  # ==================================================
38
- # ADMIN AUTH (PIN)
39
  # ==================================================
40
- ADMIN_PIN = os.environ.get("ADMIN_PIN", "4286") # CHANGE THIS
41
  ADMIN_COOKIE = "admin_session"
42
  ADMIN_SESSIONS = set()
43
 
 
 
 
44
  def is_admin(req: Request) -> bool:
45
  return req.cookies.get(ADMIN_COOKIE) in ADMIN_SESSIONS
46
 
 
 
 
 
 
 
 
47
  # ==================================================
48
  # DEFAULT ALBUM MAPS (used only if admin_config.json missing)
49
  # ==================================================
@@ -65,12 +75,11 @@ DEFAULT_ALBUM_CATEGORIES = {
65
  "B2c5ON9t3uz8kT7": "Cole Content Creator",
66
  }
67
 
68
- # In-memory config (loaded at startup)
69
  ALBUM_PUBLISHERS = dict(DEFAULT_ALBUM_PUBLISHERS)
70
  ALBUM_CATEGORIES = dict(DEFAULT_ALBUM_CATEGORIES)
71
 
72
  # ==================================================
73
- # LOCKS (avoid index.json corruption)
74
  # ==================================================
75
  INDEX_LOCK = asyncio.Lock()
76
  CONFIG_LOCK = asyncio.Lock()
@@ -128,7 +137,7 @@ async def refresh_album_maps_from_disk():
128
  ALBUM_CATEGORIES = dict(cfg.get("album_categories", {}))
129
 
130
  # ==================================================
131
- # BACKFILL CATEGORIES/PUBLISHERS (fix old cached entries)
132
  # ==================================================
133
  async def backfill_index_categories():
134
  try:
@@ -141,12 +150,10 @@ async def backfill_index_categories():
141
  correct_category = ALBUM_CATEGORIES.get(token, "Uncategorized")
142
  correct_publisher = ALBUM_PUBLISHERS.get(token, "Unknown")
143
 
144
- # Fix publisher if missing
145
  if v.get("publisher") in (None, "", "Unknown") and correct_publisher != "Unknown":
146
  v["publisher"] = correct_publisher
147
  changed = True
148
 
149
- # Fix category if missing or wrong
150
  if v.get("category") in (None, "", v.get("publisher")) or v.get("category") != correct_category:
151
  v["category"] = correct_category
152
  changed = True
@@ -180,7 +187,6 @@ ICLOUD_HEADERS = {
180
  ICLOUD_PAYLOAD = '{"streamCtag":null}'
181
 
182
  async def get_base_url(token: str) -> str:
183
- # Keep your original logic
184
  if token and token[0] == "A":
185
  n = base62_to_int(token[1])
186
  else:
@@ -246,7 +252,6 @@ async def poll_album(token: str):
246
  guids = [p["photoGuid"] for p in metadata]
247
  assets = await get_asset_urls(base_url, guids)
248
 
249
- # Snapshot known IDs under lock (so we don't race writes)
250
  async with INDEX_LOCK:
251
  index = load_index_sync()
252
  known = {v["id"] for v in index.get("videos", [])}
@@ -314,11 +319,10 @@ async def poll_album(token: str):
314
  save_index_sync(idx)
315
 
316
  # ==================================================
317
- # STARTUP: LOAD CONFIG + BACKFILL + START POLLER
318
  # ==================================================
319
  @app.on_event("startup")
320
  async def start_polling():
321
- # Ensure config exists
322
  if not os.path.exists(ADMIN_CONFIG_FILE):
323
  save_admin_config_sync({
324
  "album_publishers": dict(DEFAULT_ALBUM_PUBLISHERS),
@@ -328,9 +332,11 @@ async def start_polling():
328
  await refresh_album_maps_from_disk()
329
  await backfill_index_categories()
330
 
 
 
 
331
  async def loop():
332
  while True:
333
- # Iterate dynamic albums from config
334
  tokens = list(ALBUM_PUBLISHERS.keys())
335
  for token in tokens:
336
  try:
@@ -350,7 +356,7 @@ async def get_video_feed():
350
  return load_index_sync()
351
 
352
  # ==================================================
353
- # LEGACY ENDPOINTS (unchanged)
354
  # ==================================================
355
  @app.get("/album/{token}")
356
  async def legacy_album(token: str):
@@ -399,7 +405,12 @@ async def legacy_album_raw(token: str):
399
  # ==================================================
400
  @app.get("/admin/login", response_class=HTMLResponse)
401
  async def admin_login_page():
402
- # No f-string here -> no brace issues
 
 
 
 
 
403
  return """
404
  <html>
405
  <head>
@@ -411,22 +422,27 @@ async def admin_login_page():
411
  input, button { width:100%; padding:12px; font-size:16px; border-radius:10px; border:1px solid #333; background:#0f0f0f; color:#fff; }
412
  button { background:#2a2a2a; cursor:pointer; margin-top:10px; }
413
  button:hover { background:#3a3a3a; }
 
414
  </style>
415
  </head>
416
  <body>
417
  <form class="card" method="post">
418
  <h2 style="margin:0 0 10px 0;">Admin Panel</h2>
419
- <input type="password" name="pin" placeholder="PIN" autofocus />
420
  <button type="submit">Enter</button>
 
421
  </form>
422
  </body>
423
  </html>
424
  """
425
 
426
  @app.post("/admin/login")
427
- async def admin_login(pin: str = Form(...)):
428
- if pin != ADMIN_PIN:
429
- return HTMLResponse("Wrong PIN", status_code=401)
 
 
 
430
 
431
  session = secrets.token_hex(16)
432
  ADMIN_SESSIONS.add(session)
@@ -445,7 +461,7 @@ async def admin_logout(req: Request):
445
  return resp
446
 
447
  # ==================================================
448
- # ADMIN: DASHBOARD (VIDEOS + ALBUMS)
449
  # ==================================================
450
  def esc(v) -> str:
451
  return html_escape_lib.escape("" if v is None else str(v), quote=True)
@@ -468,6 +484,8 @@ ADMIN_TEMPLATE = """
468
  input { width:100%; padding:8px; border-radius:10px; border:1px solid #333; background:#0f0f0f; color:#fff; }
469
  button { padding:10px 12px; border-radius:10px; border:1px solid #333; background:#2a2a2a; color:#fff; cursor:pointer; }
470
  button:hover { background:#3a3a3a; }
 
 
471
  .small { font-size:12px; opacity:0.85; }
472
  .grid2 { display:grid; grid-template-columns: 1fr 1fr; gap:10px; }
473
  @media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; } }
@@ -476,7 +494,7 @@ ADMIN_TEMPLATE = """
476
  <body>
477
  <div class="row">
478
  <h1 style="margin-right:auto;">Admin</h1>
479
- <span class="pill">PIN session active</span>
480
  <a href="/admin/logout" class="pill">Logout</a>
481
  </div>
482
 
@@ -509,17 +527,20 @@ ADMIN_TEMPLATE = """
509
 
510
  <div class="card">
511
  <h2 style="margin:0 0 8px 0;">Videos</h2>
512
- <div class="small">Edits save to index.json instantly on field change.</div>
 
 
513
  <table>
514
  <thead>
515
  <tr>
516
- <th style="width:13%;">ID</th>
517
- <th style="width:16%;">Name</th>
518
  <th style="width:16%;">Upload Date</th>
519
  <th style="width:10%;">Category</th>
520
  <th style="width:12%;">Publisher</th>
521
  <th style="width:18%;">Thumbnail</th>
522
- <th style="width:15%;">Source Album</th>
 
523
  </tr>
524
  </thead>
525
  <tbody>
@@ -543,11 +564,11 @@ ADMIN_TEMPLATE = """
543
  const t = e.target;
544
  if (!t || !t.dataset || !t.dataset.id) return;
545
 
546
- const id = t.dataset.id;
547
- const field = t.dataset.field;
548
- const value = t.value;
549
-
550
- await postJSON("/admin/update", { id, field, value });
551
  });
552
 
553
  // autosave album fields
@@ -555,11 +576,11 @@ ADMIN_TEMPLATE = """
555
  const t = e.target;
556
  if (!t || !t.dataset || !t.dataset.album) return;
557
 
558
- const token = t.dataset.album;
559
- const field = t.dataset.field;
560
- const value = t.value;
561
-
562
- await postJSON("/admin/albums/update", { token, field, value });
563
  });
564
 
565
  async function addAlbum() {
@@ -572,13 +593,25 @@ ADMIN_TEMPLATE = """
572
  if (out && out.ok) location.reload();
573
  else alert(out.error || "failed");
574
  }
 
 
 
 
 
 
 
575
  </script>
576
  </body>
577
  </html>
578
  """
579
 
 
 
 
580
  @app.get("/admin", response_class=HTMLResponse)
581
  async def admin_dashboard(req: Request):
 
 
582
  if not is_admin(req):
583
  return RedirectResponse("/admin/login", status_code=302)
584
 
@@ -586,7 +619,6 @@ async def admin_dashboard(req: Request):
586
  idx = load_index_sync()
587
  cfg = await load_admin_config()
588
 
589
- # Build album rows
590
  ap = cfg.get("album_publishers", {})
591
  ac = cfg.get("album_categories", {})
592
  album_tokens = sorted(set(ap.keys()) | set(ac.keys()))
@@ -601,7 +633,6 @@ async def admin_dashboard(req: Request):
601
  "</tr>"
602
  )
603
 
604
- # Build video rows
605
  video_rows = ""
606
  for v in idx.get("videos", []):
607
  vid = esc(v.get("id", ""))
@@ -612,8 +643,9 @@ async def admin_dashboard(req: Request):
612
  f"<td><input data-id=\"{vid}\" data-field=\"upload_date\" value=\"{esc(v.get('upload_date',''))}\"></td>"
613
  f"<td><input data-id=\"{vid}\" data-field=\"category\" value=\"{esc(v.get('category',''))}\"></td>"
614
  f"<td><input data-id=\"{vid}\" data-field=\"publisher\" value=\"{esc(v.get('publisher',''))}\"></td>"
615
- f"<td><input data-id=\"{vid}\" data-field=\"thumbnail\" value=\"{esc(v.get('thumbnail',''))}\"></td>"
616
  f"<td><input data-id=\"{vid}\" data-field=\"source_album\" value=\"{esc(v.get('source_album',''))}\"></td>"
 
617
  "</tr>"
618
  )
619
 
@@ -623,14 +655,12 @@ async def admin_dashboard(req: Request):
623
  # ==================================================
624
  # ADMIN: UPDATE VIDEO METADATA
625
  # ==================================================
626
- ALLOWED_VIDEO_FIELDS = {
627
- "name", "upload_date", "category", "publisher", "thumbnail", "source_album",
628
- # (Optional) allow these if you want:
629
- # "video_url",
630
- }
631
 
632
  @app.post("/admin/update")
633
  async def admin_update(req: Request, payload: dict):
 
 
634
  if not is_admin(req):
635
  return JSONResponse({"error": "unauthorized"}, status_code=403)
636
 
@@ -651,25 +681,73 @@ async def admin_update(req: Request, payload: dict):
651
 
652
  return JSONResponse({"error": "video not found"}, status_code=404)
653
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
654
  # ==================================================
655
  # ADMIN: ALBUM MANAGEMENT (persisted)
656
  # ==================================================
657
  @app.post("/admin/albums/add")
658
  async def admin_albums_add(req: Request, payload: dict):
 
 
659
  if not is_admin(req):
660
  return JSONResponse({"error": "unauthorized"}, status_code=403)
661
 
662
  token = str(payload.get("token", "")).strip()
663
  publisher = str(payload.get("publisher", "")).strip() or "Unknown"
664
  category = str(payload.get("category", "")).strip() or "Uncategorized"
665
-
666
  if not token:
667
  return JSONResponse({"error": "token required"}, status_code=400)
668
 
669
  cfg = await load_admin_config()
670
  cfg.setdefault("album_publishers", {})
671
  cfg.setdefault("album_categories", {})
672
-
673
  cfg["album_publishers"][token] = publisher
674
  cfg["album_categories"][token] = category
675
 
@@ -681,6 +759,8 @@ async def admin_albums_add(req: Request, payload: dict):
681
 
682
  @app.post("/admin/albums/update")
683
  async def admin_albums_update(req: Request, payload: dict):
 
 
684
  if not is_admin(req):
685
  return JSONResponse({"error": "unauthorized"}, status_code=403)
686
 
 
35
  os.makedirs(VIDEO_DIR, exist_ok=True)
36
 
37
  # ==================================================
38
+ # ADMIN AUTH (ENV ONLY)
39
  # ==================================================
40
+ ADMIN_KEY = os.environ.get("ADMIN_KEY") # REQUIRED (no default; code can be public)
41
  ADMIN_COOKIE = "admin_session"
42
  ADMIN_SESSIONS = set()
43
 
44
+ def admin_enabled() -> bool:
45
+ return bool(ADMIN_KEY and str(ADMIN_KEY).strip())
46
+
47
  def is_admin(req: Request) -> bool:
48
  return req.cookies.get(ADMIN_COOKIE) in ADMIN_SESSIONS
49
 
50
+ def secure_equals(a: str, b: str) -> bool:
51
+ # constant-time compare
52
+ try:
53
+ return secrets.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
54
+ except Exception:
55
+ return False
56
+
57
  # ==================================================
58
  # DEFAULT ALBUM MAPS (used only if admin_config.json missing)
59
  # ==================================================
 
75
  "B2c5ON9t3uz8kT7": "Cole Content Creator",
76
  }
77
 
 
78
  ALBUM_PUBLISHERS = dict(DEFAULT_ALBUM_PUBLISHERS)
79
  ALBUM_CATEGORIES = dict(DEFAULT_ALBUM_CATEGORIES)
80
 
81
  # ==================================================
82
+ # LOCKS (avoid json corruption)
83
  # ==================================================
84
  INDEX_LOCK = asyncio.Lock()
85
  CONFIG_LOCK = asyncio.Lock()
 
137
  ALBUM_CATEGORIES = dict(cfg.get("album_categories", {}))
138
 
139
  # ==================================================
140
+ # BACKFILL CATEGORIES/PUBLISHERS
141
  # ==================================================
142
  async def backfill_index_categories():
143
  try:
 
150
  correct_category = ALBUM_CATEGORIES.get(token, "Uncategorized")
151
  correct_publisher = ALBUM_PUBLISHERS.get(token, "Unknown")
152
 
 
153
  if v.get("publisher") in (None, "", "Unknown") and correct_publisher != "Unknown":
154
  v["publisher"] = correct_publisher
155
  changed = True
156
 
 
157
  if v.get("category") in (None, "", v.get("publisher")) or v.get("category") != correct_category:
158
  v["category"] = correct_category
159
  changed = True
 
187
  ICLOUD_PAYLOAD = '{"streamCtag":null}'
188
 
189
  async def get_base_url(token: str) -> str:
 
190
  if token and token[0] == "A":
191
  n = base62_to_int(token[1])
192
  else:
 
252
  guids = [p["photoGuid"] for p in metadata]
253
  assets = await get_asset_urls(base_url, guids)
254
 
 
255
  async with INDEX_LOCK:
256
  index = load_index_sync()
257
  known = {v["id"] for v in index.get("videos", [])}
 
319
  save_index_sync(idx)
320
 
321
  # ==================================================
322
+ # STARTUP
323
  # ==================================================
324
  @app.on_event("startup")
325
  async def start_polling():
 
326
  if not os.path.exists(ADMIN_CONFIG_FILE):
327
  save_admin_config_sync({
328
  "album_publishers": dict(DEFAULT_ALBUM_PUBLISHERS),
 
332
  await refresh_album_maps_from_disk()
333
  await backfill_index_categories()
334
 
335
+ if not admin_enabled():
336
+ logging.warning("ADMIN_KEY is not set. Admin panel is disabled until you set ADMIN_KEY.")
337
+
338
  async def loop():
339
  while True:
 
340
  tokens = list(ALBUM_PUBLISHERS.keys())
341
  for token in tokens:
342
  try:
 
356
  return load_index_sync()
357
 
358
  # ==================================================
359
+ # LEGACY ENDPOINTS
360
  # ==================================================
361
  @app.get("/album/{token}")
362
  async def legacy_album(token: str):
 
405
  # ==================================================
406
  @app.get("/admin/login", response_class=HTMLResponse)
407
  async def admin_login_page():
408
+ if not admin_enabled():
409
+ return HTMLResponse(
410
+ "<h3>Admin disabled</h3><p>Set environment variable <code>ADMIN_KEY</code> to enable.</p>",
411
+ status_code=503
412
+ )
413
+
414
  return """
415
  <html>
416
  <head>
 
422
  input, button { width:100%; padding:12px; font-size:16px; border-radius:10px; border:1px solid #333; background:#0f0f0f; color:#fff; }
423
  button { background:#2a2a2a; cursor:pointer; margin-top:10px; }
424
  button:hover { background:#3a3a3a; }
425
+ .small { font-size:12px; opacity:0.8; margin-top:10px; }
426
  </style>
427
  </head>
428
  <body>
429
  <form class="card" method="post">
430
  <h2 style="margin:0 0 10px 0;">Admin Panel</h2>
431
+ <input type="password" name="key" placeholder="ADMIN_KEY" autofocus />
432
  <button type="submit">Enter</button>
433
+ <div class="small">This requires <code>ADMIN_KEY</code> env var set on the server.</div>
434
  </form>
435
  </body>
436
  </html>
437
  """
438
 
439
  @app.post("/admin/login")
440
+ async def admin_login(key: str = Form(...)):
441
+ if not admin_enabled():
442
+ return HTMLResponse("Admin disabled (ADMIN_KEY not set)", status_code=503)
443
+
444
+ if not secure_equals(str(key).strip(), str(ADMIN_KEY).strip()):
445
+ return HTMLResponse("Wrong key", status_code=401)
446
 
447
  session = secrets.token_hex(16)
448
  ADMIN_SESSIONS.add(session)
 
461
  return resp
462
 
463
  # ==================================================
464
+ # ADMIN UI TEMPLATE
465
  # ==================================================
466
  def esc(v) -> str:
467
  return html_escape_lib.escape("" if v is None else str(v), quote=True)
 
484
  input { width:100%; padding:8px; border-radius:10px; border:1px solid #333; background:#0f0f0f; color:#fff; }
485
  button { padding:10px 12px; border-radius:10px; border:1px solid #333; background:#2a2a2a; color:#fff; cursor:pointer; }
486
  button:hover { background:#3a3a3a; }
487
+ .danger { background:#3a1d1d; border-color:#6a2a2a; }
488
+ .danger:hover { background:#5a2424; }
489
  .small { font-size:12px; opacity:0.85; }
490
  .grid2 { display:grid; grid-template-columns: 1fr 1fr; gap:10px; }
491
  @media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; } }
 
494
  <body>
495
  <div class="row">
496
  <h1 style="margin-right:auto;">Admin</h1>
497
+ <span class="pill">Session active</span>
498
  <a href="/admin/logout" class="pill">Logout</a>
499
  </div>
500
 
 
527
 
528
  <div class="card">
529
  <h2 style="margin:0 0 8px 0;">Videos</h2>
530
+ <div class="small">
531
+ Thumbnail supports either a local path like <code>/media/TOKEN/ID.jpg</code> or a full URL like <code>https://...</code>
532
+ </div>
533
  <table>
534
  <thead>
535
  <tr>
536
+ <th style="width:12%;">ID</th>
537
+ <th style="width:14%;">Name</th>
538
  <th style="width:16%;">Upload Date</th>
539
  <th style="width:10%;">Category</th>
540
  <th style="width:12%;">Publisher</th>
541
  <th style="width:18%;">Thumbnail</th>
542
+ <th style="width:13%;">Source Album</th>
543
+ <th style="width:5%;">Action</th>
544
  </tr>
545
  </thead>
546
  <tbody>
 
564
  const t = e.target;
565
  if (!t || !t.dataset || !t.dataset.id) return;
566
 
567
+ await postJSON("/admin/update", {
568
+ id: t.dataset.id,
569
+ field: t.dataset.field,
570
+ value: t.value
571
+ });
572
  });
573
 
574
  // autosave album fields
 
576
  const t = e.target;
577
  if (!t || !t.dataset || !t.dataset.album) return;
578
 
579
+ await postJSON("/admin/albums/update", {
580
+ token: t.dataset.album,
581
+ field: t.dataset.field,
582
+ value: t.value
583
+ });
584
  });
585
 
586
  async function addAlbum() {
 
593
  if (out && out.ok) location.reload();
594
  else alert(out.error || "failed");
595
  }
596
+
597
+ async function deleteVideo(id) {
598
+ if (!confirm("Delete this video? This removes it from the feed and deletes local files if present.")) return;
599
+ const out = await postJSON("/admin/videos/delete", { id });
600
+ if (out && out.ok) location.reload();
601
+ else alert(out.error || "failed");
602
+ }
603
  </script>
604
  </body>
605
  </html>
606
  """
607
 
608
+ # ==================================================
609
+ # ADMIN: DASHBOARD
610
+ # ==================================================
611
  @app.get("/admin", response_class=HTMLResponse)
612
  async def admin_dashboard(req: Request):
613
+ if not admin_enabled():
614
+ return HTMLResponse("Admin disabled (ADMIN_KEY not set)", status_code=503)
615
  if not is_admin(req):
616
  return RedirectResponse("/admin/login", status_code=302)
617
 
 
619
  idx = load_index_sync()
620
  cfg = await load_admin_config()
621
 
 
622
  ap = cfg.get("album_publishers", {})
623
  ac = cfg.get("album_categories", {})
624
  album_tokens = sorted(set(ap.keys()) | set(ac.keys()))
 
633
  "</tr>"
634
  )
635
 
 
636
  video_rows = ""
637
  for v in idx.get("videos", []):
638
  vid = esc(v.get("id", ""))
 
643
  f"<td><input data-id=\"{vid}\" data-field=\"upload_date\" value=\"{esc(v.get('upload_date',''))}\"></td>"
644
  f"<td><input data-id=\"{vid}\" data-field=\"category\" value=\"{esc(v.get('category',''))}\"></td>"
645
  f"<td><input data-id=\"{vid}\" data-field=\"publisher\" value=\"{esc(v.get('publisher',''))}\"></td>"
646
+ f"<td><input data-id=\"{vid}\" data-field=\"thumbnail\" value=\"{esc(v.get('thumbnail',''))}\" placeholder=\"/media/TOKEN/ID.jpg or https://...\"></td>"
647
  f"<td><input data-id=\"{vid}\" data-field=\"source_album\" value=\"{esc(v.get('source_album',''))}\"></td>"
648
+ f"<td><button class=\"danger\" onclick=\"deleteVideo('{vid}')\">Del</button></td>"
649
  "</tr>"
650
  )
651
 
 
655
  # ==================================================
656
  # ADMIN: UPDATE VIDEO METADATA
657
  # ==================================================
658
+ ALLOWED_VIDEO_FIELDS = {"name", "upload_date", "category", "publisher", "thumbnail", "source_album"}
 
 
 
 
659
 
660
  @app.post("/admin/update")
661
  async def admin_update(req: Request, payload: dict):
662
+ if not admin_enabled():
663
+ return JSONResponse({"error": "admin disabled"}, status_code=503)
664
  if not is_admin(req):
665
  return JSONResponse({"error": "unauthorized"}, status_code=403)
666
 
 
681
 
682
  return JSONResponse({"error": "video not found"}, status_code=404)
683
 
684
+ # ==================================================
685
+ # ADMIN: DELETE VIDEO (index + local files)
686
+ # ==================================================
687
+ def _safe_remove_file(path: str):
688
+ try:
689
+ if path and os.path.isfile(path):
690
+ os.remove(path)
691
+ except Exception:
692
+ logging.exception(f"Failed to remove file: {path}")
693
+
694
+ @app.post("/admin/videos/delete")
695
+ async def admin_delete_video(req: Request, payload: dict):
696
+ if not admin_enabled():
697
+ return JSONResponse({"error": "admin disabled"}, status_code=503)
698
+ if not is_admin(req):
699
+ return JSONResponse({"error": "unauthorized"}, status_code=403)
700
+
701
+ vid = str(payload.get("id", "")).strip()
702
+ if not vid:
703
+ return JSONResponse({"error": "id required"}, status_code=400)
704
+
705
+ async with INDEX_LOCK:
706
+ idx = load_index_sync()
707
+ videos = idx.get("videos", [])
708
+
709
+ target = None
710
+ for v in videos:
711
+ if v.get("id") == vid:
712
+ target = v
713
+ break
714
+
715
+ if not target:
716
+ return JSONResponse({"error": "video not found"}, status_code=404)
717
+
718
+ # Delete local files if they exist
719
+ token = target.get("source_album", "")
720
+ if token:
721
+ mp4_path = os.path.join(VIDEO_DIR, token, f"{vid}.mp4")
722
+ jpg_path = os.path.join(VIDEO_DIR, token, f"{vid}.jpg")
723
+ _safe_remove_file(mp4_path)
724
+ _safe_remove_file(jpg_path)
725
+
726
+ # Remove from index
727
+ idx["videos"] = [v for v in videos if v.get("id") != vid]
728
+ save_index_sync(idx)
729
+
730
+ return {"ok": True}
731
+
732
  # ==================================================
733
  # ADMIN: ALBUM MANAGEMENT (persisted)
734
  # ==================================================
735
  @app.post("/admin/albums/add")
736
  async def admin_albums_add(req: Request, payload: dict):
737
+ if not admin_enabled():
738
+ return JSONResponse({"error": "admin disabled"}, status_code=503)
739
  if not is_admin(req):
740
  return JSONResponse({"error": "unauthorized"}, status_code=403)
741
 
742
  token = str(payload.get("token", "")).strip()
743
  publisher = str(payload.get("publisher", "")).strip() or "Unknown"
744
  category = str(payload.get("category", "")).strip() or "Uncategorized"
 
745
  if not token:
746
  return JSONResponse({"error": "token required"}, status_code=400)
747
 
748
  cfg = await load_admin_config()
749
  cfg.setdefault("album_publishers", {})
750
  cfg.setdefault("album_categories", {})
 
751
  cfg["album_publishers"][token] = publisher
752
  cfg["album_categories"][token] = category
753
 
 
759
 
760
  @app.post("/admin/albums/update")
761
  async def admin_albums_update(req: Request, payload: dict):
762
+ if not admin_enabled():
763
+ return JSONResponse({"error": "admin disabled"}, status_code=503)
764
  if not is_admin(req):
765
  return JSONResponse({"error": "unauthorized"}, status_code=403)
766