Spaces:
Running
Running
Update app.py
Browse files
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 (
|
| 39 |
# ==================================================
|
| 40 |
-
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 420 |
<button type="submit">Enter</button>
|
|
|
|
| 421 |
</form>
|
| 422 |
</body>
|
| 423 |
</html>
|
| 424 |
"""
|
| 425 |
|
| 426 |
@app.post("/admin/login")
|
| 427 |
-
async def admin_login(
|
| 428 |
-
if
|
| 429 |
-
return HTMLResponse("
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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">
|
| 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">
|
|
|
|
|
|
|
| 513 |
<table>
|
| 514 |
<thead>
|
| 515 |
<tr>
|
| 516 |
-
<th style="width:
|
| 517 |
-
<th style="width:
|
| 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:
|
|
|
|
| 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 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 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 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 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 |
|