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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +399 -590
app.py CHANGED
@@ -1,6 +1,5 @@
1
- from fastapi import FastAPI, Request, Form
2
  from fastapi.middleware.cors import CORSMiddleware
3
- from fastapi.staticfiles import StaticFiles
4
  from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
5
  import httpx
6
  import json
@@ -8,8 +7,12 @@ import logging
8
  import os
9
  import asyncio
10
  import secrets
 
 
11
  import html as html_escape_lib
 
12
  from datetime import datetime, timezone
 
13
 
14
  # ==================================================
15
  # APP SETUP
@@ -25,19 +28,24 @@ app.add_middleware(
25
  )
26
 
27
  # ==================================================
28
- # STORAGE
29
  # ==================================================
30
- DATA_ROOT = os.environ.get("PERSISTENT_DIR", "/data")
31
- VIDEO_DIR = os.path.join(DATA_ROOT, "videos")
32
- INDEX_FILE = os.path.join(DATA_ROOT, "index.json")
33
- ADMIN_CONFIG_FILE = os.path.join(DATA_ROOT, "admin_config.json")
34
 
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
 
@@ -48,14 +56,13 @@ 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
  # ==================================================
60
  DEFAULT_ALBUM_PUBLISHERS = {
61
  "B2c5n8hH4uWRoAW": "Alex Rose",
@@ -79,97 +86,156 @@ 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()
 
 
 
86
 
87
  # ==================================================
88
  # HTTP CLIENT
89
  # ==================================================
90
  async def get_client() -> httpx.AsyncClient:
91
  if not hasattr(app.state, "client"):
92
- app.state.client = httpx.AsyncClient(timeout=20.0)
93
  return app.state.client
94
 
95
  # ==================================================
96
- # JSON FILE HELPERS (atomic)
97
  # ==================================================
98
- def _atomic_write_json(path: str, data: dict):
99
- tmp = path + ".tmp"
100
- with open(tmp, "w", encoding="utf-8") as f:
101
- json.dump(data, f, indent=2)
102
- os.replace(tmp, path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
- def load_index_sync() -> dict:
105
- if not os.path.exists(INDEX_FILE):
106
- return {"videos": []}
107
- with open(INDEX_FILE, "r", encoding="utf-8") as f:
108
- return json.load(f)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
- def save_index_sync(data: dict):
111
- _atomic_write_json(INDEX_FILE, data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
- def load_admin_config_sync() -> dict:
114
- if not os.path.exists(ADMIN_CONFIG_FILE):
115
- return {
 
 
 
 
 
 
 
116
  "album_publishers": dict(DEFAULT_ALBUM_PUBLISHERS),
117
  "album_categories": dict(DEFAULT_ALBUM_CATEGORIES),
118
  }
119
- with open(ADMIN_CONFIG_FILE, "r", encoding="utf-8") as f:
120
- return json.load(f)
121
-
122
- def save_admin_config_sync(cfg: dict):
123
- _atomic_write_json(ADMIN_CONFIG_FILE, cfg)
124
 
125
- async def load_admin_config():
126
- async with CONFIG_LOCK:
127
- return load_admin_config_sync()
128
 
129
- async def write_admin_config(cfg: dict):
130
- async with CONFIG_LOCK:
131
- save_admin_config_sync(cfg)
132
-
133
- async def refresh_album_maps_from_disk():
134
- global ALBUM_PUBLISHERS, ALBUM_CATEGORIES
135
- cfg = await load_admin_config()
136
- ALBUM_PUBLISHERS = dict(cfg.get("album_publishers", {}))
137
- ALBUM_CATEGORIES = dict(cfg.get("album_categories", {}))
138
 
139
  # ==================================================
140
- # BACKFILL CATEGORIES/PUBLISHERS
141
  # ==================================================
142
  async def backfill_index_categories():
143
- try:
144
- async with INDEX_LOCK:
145
- idx = load_index_sync()
146
- changed = False
147
-
148
- for v in idx.get("videos", []):
149
- token = v.get("source_album", "")
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
160
-
161
- if changed:
162
- save_index_sync(idx)
163
- logging.info("Backfilled index.json categories/publishers")
164
- except Exception:
165
- logging.exception("Backfill failed (non-fatal)")
166
 
167
  # ==================================================
168
- # BASE62 HELPERS
169
  # ==================================================
170
- BASE_62_MAP = {c: i for i, c in enumerate(
171
- "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
172
- )}
173
 
174
  def base62_to_int(token: str) -> int:
175
  n = 0
@@ -177,13 +243,7 @@ def base62_to_int(token: str) -> int:
177
  n = n * 62 + BASE_62_MAP[c]
178
  return n
179
 
180
- # ==================================================
181
- # iCLOUD HELPERS
182
- # ==================================================
183
- ICLOUD_HEADERS = {
184
- "Origin": "https://www.icloud.com",
185
- "Content-Type": "text/plain",
186
- }
187
  ICLOUD_PAYLOAD = '{"streamCtag":null}'
188
 
189
  async def get_base_url(token: str) -> str:
@@ -195,31 +255,17 @@ async def get_base_url(token: str) -> str:
195
 
196
  async def get_redirected_base_url(base_url: str, token: str) -> str:
197
  client = await get_client()
198
- r = await client.post(
199
- f"{base_url}webstream",
200
- headers=ICLOUD_HEADERS,
201
- data=ICLOUD_PAYLOAD,
202
- follow_redirects=False,
203
- )
204
-
205
  if r.status_code == 330:
206
  host = r.json().get("X-Apple-MMe-Host")
207
- if not host:
208
- raise RuntimeError("Missing redirect host")
209
  return f"https://{host}/{token}/sharedstreams/"
210
-
211
  if r.status_code == 200:
212
  return base_url
213
-
214
  r.raise_for_status()
215
 
216
  async def post_json(path: str, base_url: str, payload: str) -> dict:
217
  client = await get_client()
218
- r = await client.post(
219
- f"{base_url}{path}",
220
- headers=ICLOUD_HEADERS,
221
- data=payload,
222
- )
223
  r.raise_for_status()
224
  return r.json()
225
 
@@ -230,175 +276,147 @@ async def get_asset_urls(base_url: str, guids: list) -> dict:
230
  payload = json.dumps({"photoGuids": guids})
231
  return (await post_json("webasseturls", base_url, payload)).get("items", {})
232
 
233
- # ==================================================
234
- # DOWNLOADER
235
- # ==================================================
236
- async def download_file(url: str, path: str):
237
  client = await get_client()
238
  async with client.stream("GET", url) as r:
239
  r.raise_for_status()
240
  with open(path, "wb") as f:
241
  async for chunk in r.aiter_bytes():
242
  f.write(chunk)
 
243
 
244
  # ==================================================
245
  # POLL + INGEST ALBUM
246
  # ==================================================
247
  async def poll_album(token: str):
248
- base_url = await get_base_url(token)
249
- base_url = await get_redirected_base_url(base_url, token)
250
-
251
- metadata = await get_metadata(base_url)
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", [])}
258
-
259
- publisher = ALBUM_PUBLISHERS.get(token, "Unknown")
260
- category = ALBUM_CATEGORIES.get(token, "Uncategorized")
261
-
262
- album_dir = os.path.join(VIDEO_DIR, token)
263
- os.makedirs(album_dir, exist_ok=True)
264
-
265
- new_entries = []
266
-
267
- for photo in metadata:
268
- if photo.get("mediaAssetType", "").lower() != "video":
269
- continue
270
-
271
- vid = photo["photoGuid"]
272
- if vid in known:
273
- continue
274
-
275
- derivatives = photo.get("derivatives", {})
276
- best = max(
277
- (d for k, d in derivatives.items() if k.lower() != "posterframe"),
278
- key=lambda d: int(d.get("fileSize") or 0),
279
- default=None,
280
- )
281
- if not best:
282
- continue
283
-
284
- asset = assets.get(best["checksum"])
285
- if not asset:
286
- continue
287
-
288
- video_url = f"https://{asset['url_location']}{asset['url_path']}"
289
- video_path = os.path.join(album_dir, f"{vid}.mp4")
290
- await download_file(video_url, video_path)
291
-
292
- thumb_path = ""
293
- pf = derivatives.get("PosterFrame")
294
- if pf:
295
- pf_asset = assets.get(pf.get("checksum"))
296
- if pf_asset:
297
  poster_url = f"https://{pf_asset['url_location']}{pf_asset['url_path']}"
298
- thumb_path = os.path.join(album_dir, f"{vid}.jpg")
299
- await download_file(poster_url, thumb_path)
300
-
301
- new_entries.append({
302
- "id": vid,
303
- "name": photo.get("caption") or "Untitled",
304
- "video_url": f"/media/{token}/{vid}.mp4",
305
- "thumbnail": f"/media/{token}/{vid}.jpg" if thumb_path else "",
306
- "upload_date": photo.get("creationDate") or datetime.now(timezone.utc).isoformat(),
307
- "category": category,
308
- "publisher": publisher,
309
- "source_album": token,
310
- })
311
-
312
- if new_entries:
313
- async with INDEX_LOCK:
314
- idx = load_index_sync()
315
- existing = {v["id"] for v in idx.get("videos", [])}
316
- for e in new_entries:
317
- if e["id"] not in existing:
318
- idx["videos"].append(e)
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),
329
- "album_categories": dict(DEFAULT_ALBUM_CATEGORIES),
330
- })
331
-
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:
343
- await poll_album(token)
344
- except Exception:
345
- logging.exception(f"Polling failed for {token}")
346
  await asyncio.sleep(60)
347
 
348
  asyncio.create_task(loop())
349
 
350
  # ==================================================
351
- # FEED
352
  # ==================================================
353
  @app.get("/feed/videos")
354
- async def get_video_feed():
355
- async with INDEX_LOCK:
356
- return load_index_sync()
357
-
358
- # ==================================================
359
- # LEGACY ENDPOINTS
360
- # ==================================================
361
- @app.get("/album/{token}")
362
- async def legacy_album(token: str):
363
- base_url = await get_base_url(token)
364
- base_url = await get_redirected_base_url(base_url, token)
365
- metadata = await get_metadata(base_url)
366
- guids = [p["photoGuid"] for p in metadata]
367
- assets = await get_asset_urls(base_url, guids)
368
-
369
- videos = []
370
- for photo in metadata:
371
- if photo.get("mediaAssetType", "").lower() != "video":
372
- continue
373
-
374
- derivatives = photo.get("derivatives", {})
375
- best = max(
376
- (d for k, d in derivatives.items() if k.lower() != "posterframe"),
377
- key=lambda d: int(d.get("fileSize") or 0),
378
- default=None,
379
- )
380
- if not best:
381
- continue
382
-
383
- asset = assets.get(best["checksum"])
384
- if not asset:
385
- continue
386
-
387
- videos.append({
388
- "caption": photo.get("caption", ""),
389
- "url": f"https://{asset['url_location']}{asset['url_path']}",
390
- })
391
-
392
- return {"videos": videos}
393
-
394
- @app.get("/album/{token}/raw")
395
- async def legacy_album_raw(token: str):
396
- base_url = await get_base_url(token)
397
- base_url = await get_redirected_base_url(base_url, token)
398
- metadata = await get_metadata(base_url)
399
- guids = [p["photoGuid"] for p in metadata]
400
- assets = await get_asset_urls(base_url, guids)
401
- return {"metadata": metadata, "asset_urls": assets}
402
 
403
  # ==================================================
404
  # ADMIN: LOGIN
@@ -406,387 +424,178 @@ async def legacy_album_raw(token: str):
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>
417
- <title>Admin Login</title>
418
- <meta name="viewport" content="width=device-width, initial-scale=1" />
419
- <style>
420
- body { font-family: system-ui, sans-serif; background:#111; color:#fff; display:flex; min-height:100vh; align-items:center; justify-content:center; }
421
- .card { width: 360px; padding: 18px; border: 1px solid #333; border-radius: 12px; background:#161616; }
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)
449
-
450
  resp = RedirectResponse("/admin", status_code=302)
451
  resp.set_cookie(ADMIN_COOKIE, session, httponly=True)
452
  return resp
453
 
454
  @app.get("/admin/logout")
455
  async def admin_logout(req: Request):
456
- session = req.cookies.get(ADMIN_COOKIE)
457
- if session in ADMIN_SESSIONS:
458
- ADMIN_SESSIONS.remove(session)
459
- resp = RedirectResponse("/admin/login", status_code=302)
460
- resp.delete_cookie(ADMIN_COOKIE)
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)
468
 
469
  ADMIN_TEMPLATE = """
470
  <html>
471
- <head>
472
- <title>Admin</title>
473
- <meta name="viewport" content="width=device-width, initial-scale=1" />
474
- <style>
475
- body { font-family: system-ui, sans-serif; background:#111; color:#fff; margin:0; padding:16px; }
476
- a { color:#9ad; }
477
- h1 { margin: 0 0 10px 0; }
478
- .row { display:flex; gap:10px; align-items:center; margin-bottom:12px; flex-wrap:wrap; }
479
- .pill { padding:6px 10px; border:1px solid #333; border-radius:999px; background:#161616; }
480
- .card { border:1px solid #333; border-radius:12px; background:#161616; padding:12px; margin: 14px 0; }
481
- table { width:100%; border-collapse: collapse; }
482
- th, td { border-bottom:1px solid #2a2a2a; padding:8px; vertical-align: top; }
483
- th { text-align:left; position:sticky; top:0; background:#161616; }
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; } }
492
- </style>
493
- </head>
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
-
501
- <div class="card">
502
- <h2 style="margin:0 0 8px 0;">Albums</h2>
503
- <div class="small">Add/edit albums here. Polling loop uses this list.</div>
504
- <table>
505
- <thead>
506
- <tr>
507
- <th style="width:28%;">Album Token</th>
508
- <th>Publisher</th>
509
- <th>Category</th>
510
- </tr>
511
- </thead>
512
- <tbody>
513
- __ALBUM_ROWS__
514
- <tr>
515
- <td><input id="new_token" placeholder="New token"></td>
516
- <td><input id="new_publisher" placeholder="Publisher"></td>
517
- <td>
518
- <div class="grid2">
519
- <input id="new_category" placeholder="Category">
520
- <button onclick="addAlbum()">Add Album</button>
521
- </div>
522
- </td>
523
- </tr>
524
- </tbody>
525
- </table>
526
- </div>
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>
547
- __VIDEO_ROWS__
548
- </tbody>
549
- </table>
550
- </div>
551
-
552
- <script>
553
- async function postJSON(url, obj) {
554
- const r = await fetch(url, {
555
- method: "POST",
556
- headers: { "Content-Type": "application/json" },
557
- body: JSON.stringify(obj),
558
- });
559
- return r.json();
560
- }
561
-
562
- // autosave video fields
563
- document.addEventListener("change", async (e) => {
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
575
- document.addEventListener("change", async (e) => {
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() {
587
- const token = document.getElementById("new_token").value.trim();
588
- const publisher = document.getElementById("new_publisher").value.trim();
589
- const category = document.getElementById("new_category").value.trim();
590
- if (!token) return alert("Token required");
591
-
592
- const out = await postJSON("/admin/albums/add", { token, publisher, category });
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
-
618
- async with INDEX_LOCK:
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()))
625
-
626
- album_rows = ""
627
- for token in album_tokens:
628
- album_rows += (
629
- "<tr>"
630
- f"<td><input data-album=\"{esc(token)}\" data-field=\"token\" value=\"{esc(token)}\" disabled></td>"
631
- f"<td><input data-album=\"{esc(token)}\" data-field=\"publisher\" value=\"{esc(ap.get(token, ''))}\"></td>"
632
- f"<td><input data-album=\"{esc(token)}\" data-field=\"category\" value=\"{esc(ac.get(token, ''))}\"></td>"
633
- "</tr>"
634
- )
635
-
636
- video_rows = ""
637
- for v in idx.get("videos", []):
638
- vid = esc(v.get("id", ""))
639
- video_rows += (
640
- "<tr>"
641
- f"<td class='small'>{vid}</td>"
642
- f"<td><input data-id=\"{vid}\" data-field=\"name\" value=\"{esc(v.get('name',''))}\"></td>"
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
-
652
- page = ADMIN_TEMPLATE.replace("__ALBUM_ROWS__", album_rows).replace("__VIDEO_ROWS__", video_rows)
653
- return HTMLResponse(page)
654
-
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
-
667
- vid = str(payload.get("id", "")).strip()
668
- field = str(payload.get("field", "")).strip()
669
- value = payload.get("value", "")
670
-
671
- if not vid or field not in ALLOWED_VIDEO_FIELDS:
672
- return JSONResponse({"error": "bad request"}, status_code=400)
673
-
674
- async with INDEX_LOCK:
675
- idx = load_index_sync()
676
- for v in idx.get("videos", []):
677
- if v.get("id") == vid:
678
- v[field] = value
679
- save_index_sync(idx)
 
 
 
 
 
 
680
  return {"ok": True}
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
-
754
- await write_admin_config(cfg)
755
- await refresh_album_maps_from_disk()
756
- await backfill_index_categories()
757
-
758
- return {"ok": True}
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
-
767
- token = str(payload.get("token", "")).strip()
768
- field = str(payload.get("field", "")).strip()
769
- value = str(payload.get("value", "")).strip()
770
-
771
- if not token or field not in ("publisher", "category"):
772
- return JSONResponse({"error": "bad request"}, status_code=400)
773
-
774
- cfg = await load_admin_config()
775
- cfg.setdefault("album_publishers", {})
776
- cfg.setdefault("album_categories", {})
777
-
778
- if field == "publisher":
779
- cfg["album_publishers"][token] = value or "Unknown"
780
- else:
781
- cfg["album_categories"][token] = value or "Uncategorized"
782
-
783
- await write_admin_config(cfg)
784
- await refresh_album_maps_from_disk()
785
- await backfill_index_categories()
786
-
787
- return {"ok": True}
788
-
789
- # ==================================================
790
- # STATIC MEDIA
791
- # ==================================================
792
- app.mount("/media", StaticFiles(directory=VIDEO_DIR), name="media")
 
1
+ from fastapi import FastAPI, Request, Form, HTTPException, Query
2
  from fastapi.middleware.cors import CORSMiddleware
 
3
  from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
4
  import httpx
5
  import json
 
7
  import os
8
  import asyncio
9
  import secrets
10
+ import shutil
11
+ import tempfile
12
  import html as html_escape_lib
13
+ from typing import Optional, List
14
  from datetime import datetime, timezone
15
+ from huggingface_hub import HfApi, hf_hub_download
16
 
17
  # ==================================================
18
  # APP SETUP
 
28
  )
29
 
30
  # ==================================================
31
+ # CONFIG & CONSTANTS
32
  # ==================================================
33
+ # Hugging Face Configuration
34
+ HF_TOKEN = os.environ.get("hf1")
35
+ HF_REPO_ID = "SalexAI/fundata-2.0"
36
+ HF_REPO_TYPE = "dataset"
37
 
38
+ # Division Configuration
39
+ TOTAL_DIVISIONS = 25
40
+ VALID_DIVISIONS = set(range(1, TOTAL_DIVISIONS + 1))
41
+
42
+ # Local Temporary Storage (for processing before upload)
43
+ TEMP_DIR = tempfile.mkdtemp()
44
 
45
  # ==================================================
46
+ # ADMIN AUTH
47
  # ==================================================
48
+ ADMIN_KEY = os.environ.get("ADMIN_KEY")
49
  ADMIN_COOKIE = "admin_session"
50
  ADMIN_SESSIONS = set()
51
 
 
56
  return req.cookies.get(ADMIN_COOKIE) in ADMIN_SESSIONS
57
 
58
  def secure_equals(a: str, b: str) -> bool:
 
59
  try:
60
  return secrets.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
61
  except Exception:
62
  return False
63
 
64
  # ==================================================
65
+ # DEFAULT MAPS
66
  # ==================================================
67
  DEFAULT_ALBUM_PUBLISHERS = {
68
  "B2c5n8hH4uWRoAW": "Alex Rose",
 
86
  ALBUM_CATEGORIES = dict(DEFAULT_ALBUM_CATEGORIES)
87
 
88
  # ==================================================
89
+ # LOCKS & STATE
90
  # ==================================================
91
+ # We use in-memory cache for the index to avoid spamming HF API,
92
+ # but we write back to HF on changes.
93
+ INDEX_CACHE = {"videos": []}
94
+ CONFIG_CACHE = {}
95
+ DATA_LOCK = asyncio.Lock()
96
 
97
  # ==================================================
98
  # HTTP CLIENT
99
  # ==================================================
100
  async def get_client() -> httpx.AsyncClient:
101
  if not hasattr(app.state, "client"):
102
+ app.state.client = httpx.AsyncClient(timeout=30.0)
103
  return app.state.client
104
 
105
  # ==================================================
106
+ # HUGGING FACE STORAGE HELPERS
107
  # ==================================================
108
+ api = HfApi(token=HF_TOKEN)
109
+
110
+ def get_hf_url(path_in_repo: str) -> str:
111
+ """Returns the direct download URL for a file in the dataset."""
112
+ return f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{path_in_repo}"
113
+
114
+ async def sync_pull_json(filename: str, default: dict) -> dict:
115
+ """Download JSON from HF. Returns default if not found."""
116
+ try:
117
+ # Run blocking HF call in thread
118
+ loop = asyncio.get_event_loop()
119
+ local_path = await loop.run_in_executor(
120
+ None,
121
+ lambda: hf_hub_download(
122
+ repo_id=HF_REPO_ID,
123
+ filename=filename,
124
+ repo_type=HF_REPO_TYPE,
125
+ token=HF_TOKEN,
126
+ local_dir=TEMP_DIR
127
+ )
128
+ )
129
+ with open(local_path, "r", encoding="utf-8") as f:
130
+ return json.load(f)
131
+ except Exception as e:
132
+ logging.warning(f"Could not pull {filename} from HF (using default): {e}")
133
+ return default
134
+
135
+ async def sync_push_json(filename: str, data: dict):
136
+ """Upload JSON to HF."""
137
+ if not HF_TOKEN:
138
+ logging.error("No HF_TOKEN set, cannot save data.")
139
+ return
140
 
141
+ try:
142
+ # Save to temp
143
+ temp_path = os.path.join(TEMP_DIR, filename)
144
+ with open(temp_path, "w", encoding="utf-8") as f:
145
+ json.dump(data, f, indent=2)
146
+
147
+ # Upload
148
+ loop = asyncio.get_event_loop()
149
+ await loop.run_in_executor(
150
+ None,
151
+ lambda: api.upload_file(
152
+ path_or_fileobj=temp_path,
153
+ path_in_repo=filename,
154
+ repo_id=HF_REPO_ID,
155
+ repo_type=HF_REPO_TYPE,
156
+ commit_message=f"Update {filename}"
157
+ )
158
+ )
159
+ except Exception as e:
160
+ logging.error(f"Failed to push {filename} to HF: {e}")
161
 
162
+ async def sync_push_file(local_path: str, remote_path: str):
163
+ """Upload media file to HF."""
164
+ if not HF_TOKEN:
165
+ return
166
+ try:
167
+ loop = asyncio.get_event_loop()
168
+ await loop.run_in_executor(
169
+ None,
170
+ lambda: api.upload_file(
171
+ path_or_fileobj=local_path,
172
+ path_in_repo=remote_path,
173
+ repo_id=HF_REPO_ID,
174
+ repo_type=HF_REPO_TYPE,
175
+ commit_message=f"Add media {remote_path}"
176
+ )
177
+ )
178
+ except Exception as e:
179
+ logging.error(f"Failed to push media {remote_path}: {e}")
180
 
181
+ # ==================================================
182
+ # STATE MANAGEMENT
183
+ # ==================================================
184
+ async def load_state_from_hf():
185
+ global INDEX_CACHE, CONFIG_CACHE, ALBUM_PUBLISHERS, ALBUM_CATEGORIES
186
+
187
+ async with DATA_LOCK:
188
+ INDEX_CACHE = await sync_pull_json("index.json", {"videos": []})
189
+
190
+ default_config = {
191
  "album_publishers": dict(DEFAULT_ALBUM_PUBLISHERS),
192
  "album_categories": dict(DEFAULT_ALBUM_CATEGORIES),
193
  }
194
+ CONFIG_CACHE = await sync_pull_json("admin_config.json", default_config)
195
+
196
+ ALBUM_PUBLISHERS = dict(CONFIG_CACHE.get("album_publishers", {}))
197
+ ALBUM_CATEGORIES = dict(CONFIG_CACHE.get("album_categories", {}))
 
198
 
199
+ async def save_index():
200
+ # Caller should hold lock usually, but atomic write helps
201
+ await sync_push_json("index.json", INDEX_CACHE)
202
 
203
+ async def save_config():
204
+ async with DATA_LOCK:
205
+ await sync_push_json("admin_config.json", CONFIG_CACHE)
 
 
 
 
 
 
206
 
207
  # ==================================================
208
+ # BACKFILL / UTILS
209
  # ==================================================
210
  async def backfill_index_categories():
211
+ async with DATA_LOCK:
212
+ changed = False
213
+ for v in INDEX_CACHE.get("videos", []):
214
+ token = v.get("source_album", "")
215
+ correct_category = ALBUM_CATEGORIES.get(token, "Uncategorized")
216
+ correct_publisher = ALBUM_PUBLISHERS.get(token, "Unknown")
217
+
218
+ if v.get("publisher") in (None, "", "Unknown") and correct_publisher != "Unknown":
219
+ v["publisher"] = correct_publisher
220
+ changed = True
221
+
222
+ if v.get("category") in (None, "", v.get("publisher")) or v.get("category") != correct_category:
223
+ v["category"] = correct_category
224
+ changed = True
225
+
226
+ # Ensure allowed_divs exists
227
+ if "allowed_divs" not in v:
228
+ v["allowed_divs"] = [] # Default to empty (Global)
229
+ changed = True
230
+
231
+ if changed:
232
+ await save_index()
233
+ logging.info("Backfilled metadata and division fields")
234
 
235
  # ==================================================
236
+ # BASE62 & ICLOUD HELPERS
237
  # ==================================================
238
+ BASE_62_MAP = {c: i for i, c in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")}
 
 
239
 
240
  def base62_to_int(token: str) -> int:
241
  n = 0
 
243
  n = n * 62 + BASE_62_MAP[c]
244
  return n
245
 
246
+ ICLOUD_HEADERS = {"Origin": "https://www.icloud.com", "Content-Type": "text/plain"}
 
 
 
 
 
 
247
  ICLOUD_PAYLOAD = '{"streamCtag":null}'
248
 
249
  async def get_base_url(token: str) -> str:
 
255
 
256
  async def get_redirected_base_url(base_url: str, token: str) -> str:
257
  client = await get_client()
258
+ r = await client.post(f"{base_url}webstream", headers=ICLOUD_HEADERS, data=ICLOUD_PAYLOAD, follow_redirects=False)
 
 
 
 
 
 
259
  if r.status_code == 330:
260
  host = r.json().get("X-Apple-MMe-Host")
 
 
261
  return f"https://{host}/{token}/sharedstreams/"
 
262
  if r.status_code == 200:
263
  return base_url
 
264
  r.raise_for_status()
265
 
266
  async def post_json(path: str, base_url: str, payload: str) -> dict:
267
  client = await get_client()
268
+ r = await client.post(f"{base_url}{path}", headers=ICLOUD_HEADERS, data=payload)
 
 
 
 
269
  r.raise_for_status()
270
  return r.json()
271
 
 
276
  payload = json.dumps({"photoGuids": guids})
277
  return (await post_json("webasseturls", base_url, payload)).get("items", {})
278
 
279
+ async def download_to_temp(url: str, filename: str) -> str:
280
+ path = os.path.join(TEMP_DIR, filename)
 
 
281
  client = await get_client()
282
  async with client.stream("GET", url) as r:
283
  r.raise_for_status()
284
  with open(path, "wb") as f:
285
  async for chunk in r.aiter_bytes():
286
  f.write(chunk)
287
+ return path
288
 
289
  # ==================================================
290
  # POLL + INGEST ALBUM
291
  # ==================================================
292
  async def poll_album(token: str):
293
+ try:
294
+ base_url = await get_base_url(token)
295
+ base_url = await get_redirected_base_url(base_url, token)
296
+
297
+ metadata = await get_metadata(base_url)
298
+ guids = [p["photoGuid"] for p in metadata]
299
+ assets = await get_asset_urls(base_url, guids)
300
+
301
+ async with DATA_LOCK:
302
+ known = {v["id"] for v in INDEX_CACHE.get("videos", [])}
303
+
304
+ publisher = ALBUM_PUBLISHERS.get(token, "Unknown")
305
+ category = ALBUM_CATEGORIES.get(token, "Uncategorized")
306
+
307
+ new_entries = []
308
+
309
+ for photo in metadata:
310
+ if photo.get("mediaAssetType", "").lower() != "video":
311
+ continue
312
+
313
+ vid = photo["photoGuid"]
314
+ if vid in known:
315
+ continue
316
+
317
+ derivatives = photo.get("derivatives", {})
318
+ best = max(
319
+ (d for k, d in derivatives.items() if k.lower() != "posterframe"),
320
+ key=lambda d: int(d.get("fileSize") or 0),
321
+ default=None,
322
+ )
323
+ if not best or best["checksum"] not in assets:
324
+ continue
325
+
326
+ asset = assets[best["checksum"]]
327
+ video_url = f"https://{asset['url_location']}{asset['url_path']}"
328
+
329
+ # Download to temp then Push to HF
330
+ temp_vid = await download_to_temp(video_url, f"{vid}.mp4")
331
+ hf_vid_path = f"videos/{token}/{vid}.mp4"
332
+ await sync_push_file(temp_vid, hf_vid_path)
333
+ os.remove(temp_vid)
334
+
335
+ # Handle Thumbnail
336
+ hf_thumb_path = ""
337
+ pf = derivatives.get("PosterFrame")
338
+ if pf and pf.get("checksum") in assets:
339
+ pf_asset = assets[pf["checksum"]]
 
 
340
  poster_url = f"https://{pf_asset['url_location']}{pf_asset['url_path']}"
341
+ temp_thumb = await download_to_temp(poster_url, f"{vid}.jpg")
342
+ hf_thumb_path = f"videos/{token}/{vid}.jpg"
343
+ await sync_push_file(temp_thumb, hf_thumb_path)
344
+ os.remove(temp_thumb)
345
+
346
+ new_entries.append({
347
+ "id": vid,
348
+ "name": photo.get("caption") or "Untitled",
349
+ "video_url": get_hf_url(hf_vid_path),
350
+ "thumbnail": get_hf_url(hf_thumb_path) if hf_thumb_path else "",
351
+ "upload_date": photo.get("creationDate") or datetime.now(timezone.utc).isoformat(),
352
+ "category": category,
353
+ "publisher": publisher,
354
+ "source_album": token,
355
+ "allowed_divs": [] # Default: Allowed for all
356
+ })
357
+
358
+ if new_entries:
359
+ async with DATA_LOCK:
360
+ INDEX_CACHE["videos"].extend(new_entries)
361
+ await save_index()
362
+ logging.info(f"Added {len(new_entries)} videos from {token}")
363
+
364
+ except Exception as e:
365
+ logging.error(f"Error polling {token}: {e}")
366
 
367
  # ==================================================
368
  # STARTUP
369
  # ==================================================
370
  @app.on_event("startup")
371
  async def start_polling():
372
+ if not HF_TOKEN:
373
+ logging.error("CRITICAL: 'hf1' environment variable not set. Dataset storage will fail.")
374
+
375
+ await load_state_from_hf()
 
 
 
376
  await backfill_index_categories()
377
 
 
 
 
378
  async def loop():
379
  while True:
380
  tokens = list(ALBUM_PUBLISHERS.keys())
381
  for token in tokens:
382
+ await poll_album(token)
 
 
 
383
  await asyncio.sleep(60)
384
 
385
  asyncio.create_task(loop())
386
 
387
  # ==================================================
388
+ # FEED (With Div Support)
389
  # ==================================================
390
  @app.get("/feed/videos")
391
+ async def get_video_feed(div: Optional[int] = Query(None, description="School Division (1-25)")):
392
+ """
393
+ Returns videos.
394
+ If 'div' is provided, returns:
395
+ 1. Videos with NO specific allowed_divs (Global).
396
+ 2. Videos where 'div' is explicitly in allowed_divs.
397
+ """
398
+ async with DATA_LOCK:
399
+ videos = INDEX_CACHE.get("videos", [])
400
+
401
+ # If no div specified, return everything? Or everything that isn't restricted?
402
+ # Usually feed returns everything accessible.
403
+ if div is None:
404
+ return {"videos": videos}
405
+
406
+ # Validate Div
407
+ if div not in VALID_DIVISIONS:
408
+ # If invalid div provided, maybe return empty or error?
409
+ # For safety, let's return empty or just global ones.
410
+ return {"videos": [v for v in videos if not v.get("allowed_divs")]}
411
+
412
+ filtered_videos = []
413
+ for v in videos:
414
+ allowed = v.get("allowed_divs", [])
415
+ # Include if: No restrictions defined OR Div is in list
416
+ if not allowed or div in allowed:
417
+ filtered_videos.append(v)
418
+
419
+ return {"videos": filtered_videos}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
 
421
  # ==================================================
422
  # ADMIN: LOGIN
 
424
  @app.get("/admin/login", response_class=HTMLResponse)
425
  async def admin_login_page():
426
  if not admin_enabled():
427
+ return HTMLResponse("Admin disabled (ADMIN_KEY missing)", status_code=503)
 
 
 
 
428
  return """
429
+ <html><body style="background:#111;color:#fff;display:flex;justify-content:center;align-items:center;height:100vh;font-family:sans-serif;">
430
+ <form method="post" style="padding:20px;border:1px solid #333;border-radius:10px;background:#1a1a1a;">
431
+ <h2>Admin</h2><input type="password" name="key" placeholder="Key" style="padding:10px;width:100%;margin-bottom:10px;">
432
+ <button style="padding:10px;width:100%;cursor:pointer;">Login</button>
433
+ </form></body></html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  """
435
 
436
  @app.post("/admin/login")
437
  async def admin_login(key: str = Form(...)):
438
+ if not admin_enabled() or not secure_equals(key.strip(), str(ADMIN_KEY).strip()):
439
+ return HTMLResponse("Unauthorized", status_code=401)
 
 
 
 
440
  session = secrets.token_hex(16)
441
  ADMIN_SESSIONS.add(session)
 
442
  resp = RedirectResponse("/admin", status_code=302)
443
  resp.set_cookie(ADMIN_COOKIE, session, httponly=True)
444
  return resp
445
 
446
  @app.get("/admin/logout")
447
  async def admin_logout(req: Request):
448
+ if (s := req.cookies.get(ADMIN_COOKIE)) in ADMIN_SESSIONS: ADMIN_SESSIONS.remove(s)
449
+ return RedirectResponse("/admin/login")
 
 
 
 
450
 
451
  # ==================================================
452
+ # ADMIN DASHBOARD
453
  # ==================================================
454
+ def esc(v) -> str: return html_escape_lib.escape("" if v is None else str(v), quote=True)
 
455
 
456
  ADMIN_TEMPLATE = """
457
  <html>
458
+ <head>
459
+ <title>Admin</title>
460
+ <style>
461
+ body{font-family:sans-serif;background:#111;color:#ddd;padding:20px;}
462
+ table{width:100%;border-collapse:collapse;margin-top:20px;}
463
+ th,td{border-bottom:1px solid #333;padding:8px;text-align:left;}
464
+ input{background:#222;border:1px solid #444;color:#fff;padding:5px;border-radius:4px;width:100%;}
465
+ button{background:#444;color:#fff;border:none;padding:5px 10px;cursor:pointer;border-radius:4px;}
466
+ .pill{padding:4px 8px;background:#004400;border-radius:10px;font-size:0.8em;}
467
+ </style>
468
+ </head>
469
+ <body>
470
+ <h1>Admin Panel <a href="/admin/logout" style="font-size:0.5em;color:#888;">Logout</a></h1>
471
+
472
+ <h3>Albums</h3>
473
+ <table>
474
+ <tr><th>Token</th><th>Publisher</th><th>Category</th><th>Action</th></tr>
475
+ __ALBUM_ROWS__
476
+ <tr>
477
+ <td><input id="n_t" placeholder="Token"></td>
478
+ <td><input id="n_p" placeholder="Publisher"></td>
479
+ <td><input id="n_c" placeholder="Category"></td>
480
+ <td><button onclick="addAlbum()">Add</button></td>
481
+ </tr>
482
+ </table>
483
+
484
+ <h3>Videos</h3>
485
+ <p style="font-size:0.8em;color:#888;">Allowed Divs: Comma separated (e.g. <code>1,5,25</code>). Leave empty for ALL.</p>
486
+ <table>
487
+ <tr><th>ID</th><th>Name</th><th>Divs (1-25)</th><th>Category</th><th>Publisher</th><th>Action</th></tr>
488
+ __VIDEO_ROWS__
489
+ </table>
490
+
491
+ <script>
492
+ async function api(ep, data) {
493
+ await fetch(ep, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(data)});
494
+ location.reload();
495
+ }
496
+ document.querySelectorAll('input[data-id]').forEach(i => {
497
+ i.addEventListener('change', (e) => api('/admin/update', {
498
+ id: e.target.dataset.id,
499
+ field: e.target.dataset.field,
500
+ value: e.target.value
501
+ }));
502
+ });
503
+ function addAlbum(){
504
+ api('/admin/albums/add', {
505
+ token: document.getElementById('n_t').value,
506
+ publisher: document.getElementById('n_p').value,
507
+ category: document.getElementById('n_c').value
508
+ });
509
+ }
510
+ </script>
511
+ </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  </html>
513
  """
514
 
 
 
 
515
  @app.get("/admin", response_class=HTMLResponse)
516
+ async def admin_dash(req: Request):
517
+ if not is_admin(req): return RedirectResponse("/admin/login")
518
+
519
+ async with DATA_LOCK:
520
+ videos = INDEX_CACHE.get("videos", [])
521
+
522
+ v_rows = ""
523
+ for v in videos:
524
+ divs = ",".join(map(str, v.get("allowed_divs", [])))
525
+ v_rows += f"""<tr>
526
+ <td>{esc(v['id'])[:8]}...</td>
527
+ <td><input data-id="{v['id']}" data-field="name" value="{esc(v.get('name'))}"></td>
528
+ <td><input data-id="{v['id']}" data-field="allowed_divs" value="{esc(divs)}" placeholder="All"></td>
529
+ <td><input data-id="{v['id']}" data-field="category" value="{esc(v.get('category'))}"></td>
530
+ <td><input data-id="{v['id']}" data-field="publisher" value="{esc(v.get('publisher'))}"></td>
531
+ <td><button onclick="api('/admin/videos/delete', {{id:'{v['id']}'}})" style="background:#500;">Del</button></td>
532
+ </tr>"""
533
+
534
+ a_rows = ""
535
+ for t, p in ALBUM_PUBLISHERS.items():
536
+ c = ALBUM_CATEGORIES.get(t, "")
537
+ a_rows += f"<tr><td>{t}</td><td>{p}</td><td>{c}</td><td>-</td></tr>"
538
+
539
+ return ADMIN_TEMPLATE.replace("__VIDEO_ROWS__", v_rows).replace("__ALBUM_ROWS__", a_rows)
540
+
541
+ # ==================================================
542
+ # ADMIN ACTIONS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
  # ==================================================
 
 
544
  @app.post("/admin/update")
545
  async def admin_update(req: Request, payload: dict):
546
+ if not is_admin(req): return JSONResponse({}, 403)
547
+
548
+ vid_id = payload.get("id")
549
+ field = payload.get("field")
550
+ value = payload.get("value")
551
+
552
+ async with DATA_LOCK:
553
+ for v in INDEX_CACHE["videos"]:
554
+ if v["id"] == vid_id:
555
+ if field == "allowed_divs":
556
+ # Parse comma string to list of ints
557
+ try:
558
+ if not value.strip():
559
+ v[field] = []
560
+ else:
561
+ # Filter only valid integers 1-25
562
+ nums = [int(x.strip()) for x in value.split(",") if x.strip().isdigit()]
563
+ v[field] = [n for n in nums if n in VALID_DIVISIONS]
564
+ except:
565
+ pass # Ignore bad input
566
+ else:
567
+ v[field] = value
568
+
569
+ await save_index()
570
  return {"ok": True}
571
+ return {"error": "not found"}
 
 
 
 
 
 
 
 
 
 
 
572
 
573
  @app.post("/admin/videos/delete")
574
+ async def admin_delete(req: Request, payload: dict):
575
+ if not is_admin(req): return JSONResponse({}, 403)
576
+ vid_id = payload.get("id")
577
+
578
+ async with DATA_LOCK:
579
+ # We only remove from index. We don't delete actual files from HF to avoid complexity
580
+ # (Git operations are heavy), but removing from index hides them from feed.
581
+ original_len = len(INDEX_CACHE["videos"])
582
+ INDEX_CACHE["videos"] = [v for v in INDEX_CACHE["videos"] if v["id"] != vid_id]
583
+
584
+ if len(INDEX_CACHE["videos"]) < original_len:
585
+ await save_index()
586
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  return {"ok": True}
588
 
 
 
 
589
  @app.post("/admin/albums/add")
590
+ async def admin_album_add(req: Request, payload: dict):
591
+ if not is_admin(req): return JSONResponse({}, 403)
592
+ t = payload.get("token")
593
+ if t:
594
+ async with DATA_LOCK:
595
+ CONFIG_CACHE["album_publishers"][t] = payload.get("publisher", "Unknown")
596
+ CONFIG_CACHE["album_categories"][t] = payload.get("category", "Uncategorized")
597
+ global ALBUM_PUBLISHERS, ALBUM_CATEGORIES
598
+ ALBUM_PUBLISHERS = CONFIG_CACHE["album_publishers"]
599
+ ALBUM_CATEGORIES = CONFIG_CACHE["album_categories"]
600
+ await save_config()
601
+ return {"ok": True}