SalexAI commited on
Commit
5cc97a0
·
verified ·
1 Parent(s): 5d85607

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +99 -565
app.py CHANGED
@@ -1,34 +1,13 @@
1
- import os
2
- import tempfile
3
-
4
- # ==================================================
5
- # CRITICAL FIX: SET HF CACHE TO WRITABLE DIR
6
- # ==================================================
7
- # Hugging Face libraries try to write to /home/user/.cache by default,
8
- # which is read-only in some Space configurations. We map it to /tmp.
9
- os.environ["HF_HOME"] = "/tmp/hf_home"
10
- os.environ["XDG_CACHE_HOME"] = "/tmp/xdg_cache"
11
-
12
- from fastapi import FastAPI, Request, Form, HTTPException, Query
13
  from fastapi.middleware.cors import CORSMiddleware
14
- from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
15
  import httpx
16
  import json
17
  import logging
18
- import asyncio
19
- import secrets
20
- import shutil
21
- import html as html_escape_lib
22
- from typing import Optional, List
23
- from datetime import datetime, timezone
24
- from huggingface_hub import HfApi, hf_hub_download
25
-
26
- # ==================================================
27
- # APP SETUP
28
- # ==================================================
29
  app = FastAPI()
30
  logging.basicConfig(level=logging.INFO)
31
 
 
32
  app.add_middleware(
33
  CORSMiddleware,
34
  allow_origins=["*"],
@@ -36,592 +15,147 @@ app.add_middleware(
36
  allow_headers=["*"],
37
  )
38
 
39
- # ==================================================
40
- # CONFIG & CONSTANTS
41
- # ==================================================
42
- # Hugging Face Configuration
43
- HF_TOKEN = os.environ.get("hf1")
44
- HF_REPO_ID = "SalexAI/fundata-2.0"
45
- HF_REPO_TYPE = "dataset"
46
-
47
- # Division Configuration
48
- TOTAL_DIVISIONS = 25
49
- VALID_DIVISIONS = set(range(1, TOTAL_DIVISIONS + 1))
50
-
51
- # Local Temporary Storage (Use system /tmp which is writable)
52
- TEMP_DIR = "/tmp/fundata_downloads"
53
- try:
54
- os.makedirs(TEMP_DIR, exist_ok=True)
55
- except OSError:
56
- TEMP_DIR = tempfile.mkdtemp()
57
-
58
- # ==================================================
59
- # ADMIN AUTH
60
- # ==================================================
61
- ADMIN_KEY = os.environ.get("ADMIN_KEY")
62
- ADMIN_COOKIE = "admin_session"
63
- ADMIN_SESSIONS = set()
64
-
65
- def admin_enabled() -> bool:
66
- return bool(ADMIN_KEY and str(ADMIN_KEY).strip())
67
-
68
- def is_admin(req: Request) -> bool:
69
- return req.cookies.get(ADMIN_COOKIE) in ADMIN_SESSIONS
70
-
71
- def secure_equals(a: str, b: str) -> bool:
72
- try:
73
- return secrets.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
74
- except Exception:
75
- return False
76
-
77
- # ==================================================
78
- # DEFAULT MAPS
79
- # ==================================================
80
- DEFAULT_ALBUM_PUBLISHERS = {
81
- "B2c5n8hH4uWRoAW": "Alex Rose",
82
- "B2c5yeZFhHXzdFg": "Central Space Program",
83
- "B2cGI9HKKtaAF3T": "Sam Holden",
84
- "B2c59UlCquMMGkJ": "Alex Rose",
85
- "B2cJ0DiRHusi12z": "Alex Rose",
86
- "B2c5ON9t3uz8kT7": "Cole Vandepoll",
87
- }
88
-
89
- DEFAULT_ALBUM_CATEGORIES = {
90
- "B2c5n8hH4uWRoAW": "Fun",
91
- "B2c5yeZFhHXzdFg": "Rockets",
92
- "B2cGI9HKKtaAF3T": "Sam Content Library",
93
- "B2c59UlCquMMGkJ": "Serious",
94
- "B2cJ0DiRHusi12z": "Music",
95
- "B2c5ON9t3uz8kT7": "Cole Content Creator",
96
- }
97
-
98
- ALBUM_PUBLISHERS = dict(DEFAULT_ALBUM_PUBLISHERS)
99
- ALBUM_CATEGORIES = dict(DEFAULT_ALBUM_CATEGORIES)
100
-
101
- # ==================================================
102
- # LOCKS & STATE
103
- # ==================================================
104
- INDEX_CACHE = {"videos": []}
105
- CONFIG_CACHE = {}
106
- DATA_LOCK = asyncio.Lock()
107
 
108
- # ==================================================
109
- # HTTP CLIENT
110
- # ==================================================
111
  async def get_client() -> httpx.AsyncClient:
112
  if not hasattr(app.state, "client"):
113
- app.state.client = httpx.AsyncClient(timeout=30.0)
114
  return app.state.client
115
 
116
- # ==================================================
117
- # HUGGING FACE STORAGE HELPERS
118
- # ==================================================
119
- api = HfApi(token=HF_TOKEN)
120
-
121
- def get_hf_url(path_in_repo: str) -> str:
122
- """Returns the direct download URL for a file in the dataset."""
123
- return f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{path_in_repo}?download=true"
124
-
125
- async def sync_pull_json(filename: str, default: dict) -> dict:
126
- """Download JSON from HF. Returns default if not found."""
127
- try:
128
- loop = asyncio.get_event_loop()
129
- local_path = await loop.run_in_executor(
130
- None,
131
- lambda: hf_hub_download(
132
- repo_id=HF_REPO_ID,
133
- filename=filename,
134
- repo_type=HF_REPO_TYPE,
135
- token=HF_TOKEN,
136
- # Explicitly use cache_dir to avoid permission errors
137
- cache_dir="/tmp/hf_cache",
138
- local_dir=TEMP_DIR
139
- )
140
- )
141
- with open(local_path, "r", encoding="utf-8") as f:
142
- return json.load(f)
143
- except Exception as e:
144
- logging.warning(f"Could not pull {filename} from HF (using default): {e}")
145
- return default
146
-
147
- async def sync_push_json(filename: str, data: dict):
148
- """Upload JSON to HF."""
149
- if not HF_TOKEN:
150
- logging.error("No HF_TOKEN set, cannot save data.")
151
- return
152
-
153
- try:
154
- temp_path = os.path.join(TEMP_DIR, filename)
155
- with open(temp_path, "w", encoding="utf-8") as f:
156
- json.dump(data, f, indent=2)
157
-
158
- loop = asyncio.get_event_loop()
159
- await loop.run_in_executor(
160
- None,
161
- lambda: api.upload_file(
162
- path_or_fileobj=temp_path,
163
- path_in_repo=filename,
164
- repo_id=HF_REPO_ID,
165
- repo_type=HF_REPO_TYPE,
166
- commit_message=f"Update {filename}"
167
- )
168
- )
169
- except Exception as e:
170
- logging.error(f"Failed to push {filename} to HF: {e}")
171
-
172
- async def sync_push_file(local_path: str, remote_path: str) -> bool:
173
- """Upload media file to HF. Returns True if successful."""
174
- if not HF_TOKEN:
175
- return False
176
-
177
- if not os.path.exists(local_path):
178
- logging.error(f"Upload failed: Local file not found at {local_path}")
179
- return False
180
-
181
- try:
182
- loop = asyncio.get_event_loop()
183
- await loop.run_in_executor(
184
- None,
185
- lambda: api.upload_file(
186
- path_or_fileobj=local_path,
187
- path_in_repo=remote_path,
188
- repo_id=HF_REPO_ID,
189
- repo_type=HF_REPO_TYPE,
190
- commit_message=f"Add media {remote_path}"
191
- )
192
- )
193
- await asyncio.sleep(2.0)
194
- return True
195
- except Exception as e:
196
- logging.error(f"Failed to push media {remote_path}: {e}")
197
- return False
198
-
199
- # ==================================================
200
- # STATE MANAGEMENT
201
- # ==================================================
202
- async def load_state_from_hf():
203
- global INDEX_CACHE, CONFIG_CACHE, ALBUM_PUBLISHERS, ALBUM_CATEGORIES
204
-
205
- async with DATA_LOCK:
206
- INDEX_CACHE = await sync_pull_json("index.json", {"videos": []})
207
-
208
- default_config = {
209
- "album_publishers": dict(DEFAULT_ALBUM_PUBLISHERS),
210
- "album_categories": dict(DEFAULT_ALBUM_CATEGORIES),
211
- }
212
- CONFIG_CACHE = await sync_pull_json("admin_config.json", default_config)
213
-
214
- ALBUM_PUBLISHERS = dict(CONFIG_CACHE.get("album_publishers", {}))
215
- ALBUM_CATEGORIES = dict(CONFIG_CACHE.get("album_categories", {}))
216
-
217
- async def save_index():
218
- await sync_push_json("index.json", INDEX_CACHE)
219
-
220
- async def save_config():
221
- async with DATA_LOCK:
222
- await sync_push_json("admin_config.json", CONFIG_CACHE)
223
-
224
- # ==================================================
225
- # BACKFILL / UTILS
226
- # ==================================================
227
- async def backfill_index_categories():
228
- async with DATA_LOCK:
229
- changed = False
230
- for v in INDEX_CACHE.get("videos", []):
231
- token = v.get("source_album", "")
232
- correct_category = ALBUM_CATEGORIES.get(token, "Uncategorized")
233
- correct_publisher = ALBUM_PUBLISHERS.get(token, "Unknown")
234
-
235
- if v.get("publisher") in (None, "", "Unknown") and correct_publisher != "Unknown":
236
- v["publisher"] = correct_publisher
237
- changed = True
238
-
239
- if v.get("category") in (None, "", v.get("publisher")) or v.get("category") != correct_category:
240
- v["category"] = correct_category
241
- changed = True
242
-
243
- if "allowed_divs" not in v:
244
- v["allowed_divs"] = []
245
- changed = True
246
-
247
- if changed:
248
- await save_index()
249
- logging.info("Backfilled metadata and division fields")
250
-
251
- # ==================================================
252
- # BASE62 & ICLOUD HELPERS
253
- # ==================================================
254
- BASE_62_MAP = {c: i for i, c in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")}
255
-
256
  def base62_to_int(token: str) -> int:
257
- n = 0
258
- for c in token:
259
- n = n * 62 + BASE_62_MAP[c]
260
- return n
261
-
262
- ICLOUD_HEADERS = {"Origin": "https://www.icloud.com", "Content-Type": "text/plain"}
263
- ICLOUD_PAYLOAD = '{"streamCtag":null}'
264
 
265
  async def get_base_url(token: str) -> str:
266
- if token and token[0] == "A":
 
267
  n = base62_to_int(token[1])
268
  else:
269
  n = base62_to_int(token[1:3])
270
  return f"https://p{n:02d}-sharedstreams.icloud.com/{token}/sharedstreams/"
271
 
 
 
 
 
 
 
272
  async def get_redirected_base_url(base_url: str, token: str) -> str:
273
  client = await get_client()
274
- r = await client.post(f"{base_url}webstream", headers=ICLOUD_HEADERS, data=ICLOUD_PAYLOAD, follow_redirects=False)
275
- if r.status_code == 330:
276
- host = r.json().get("X-Apple-MMe-Host")
277
- return f"https://{host}/{token}/sharedstreams/"
278
- if r.status_code == 200:
 
 
 
 
 
 
 
 
 
 
279
  return base_url
280
- r.raise_for_status()
 
281
 
282
  async def post_json(path: str, base_url: str, payload: str) -> dict:
283
  client = await get_client()
284
- r = await client.post(f"{base_url}{path}", headers=ICLOUD_HEADERS, data=payload)
285
- r.raise_for_status()
286
- return r.json()
287
 
288
  async def get_metadata(base_url: str) -> list:
289
- return (await post_json("webstream", base_url, ICLOUD_PAYLOAD)).get("photos", [])
 
290
 
291
  async def get_asset_urls(base_url: str, guids: list) -> dict:
292
  payload = json.dumps({"photoGuids": guids})
293
- return (await post_json("webasseturls", base_url, payload)).get("items", {})
 
294
 
295
- async def download_to_temp(url: str, filename: str) -> str:
296
- path = os.path.join(TEMP_DIR, filename)
297
- if os.path.exists(path):
298
- os.remove(path)
299
-
300
- client = await get_client()
301
- async with client.stream("GET", url) as r:
302
- r.raise_for_status()
303
- with open(path, "wb") as f:
304
- async for chunk in r.aiter_bytes():
305
- f.write(chunk)
306
- return path
307
-
308
- # ==================================================
309
- # POLL + INGEST ALBUM
310
- # ==================================================
311
- async def poll_album(token: str):
312
  try:
313
  base_url = await get_base_url(token)
314
  base_url = await get_redirected_base_url(base_url, token)
315
 
316
  metadata = await get_metadata(base_url)
317
- if not metadata:
318
- return
319
-
320
- guids = [p["photoGuid"] for p in metadata]
321
- if not guids:
322
- return
323
-
324
- assets = await get_asset_urls(base_url, guids)
325
-
326
- async with DATA_LOCK:
327
- known = {v["id"] for v in INDEX_CACHE.get("videos", [])}
328
-
329
- publisher = ALBUM_PUBLISHERS.get(token, "Unknown")
330
- category = ALBUM_CATEGORIES.get(token, "Uncategorized")
331
-
332
- new_entries = []
333
 
 
334
  for photo in metadata:
335
  if photo.get("mediaAssetType", "").lower() != "video":
336
  continue
337
 
338
- vid = photo["photoGuid"]
339
- if vid in known:
340
- continue
341
-
342
  derivatives = photo.get("derivatives", {})
343
  best = max(
344
  (d for k, d in derivatives.items() if k.lower() != "posterframe"),
345
  key=lambda d: int(d.get("fileSize") or 0),
346
- default=None,
347
  )
348
- if not best or best["checksum"] not in assets:
349
  continue
350
 
351
- asset = assets[best["checksum"]]
352
- video_url = f"https://{asset['url_location']}{asset['url_path']}"
353
-
354
- temp_vid = await download_to_temp(video_url, f"{vid}.mp4")
355
- hf_vid_path = f"videos/{token}/{vid}.mp4"
356
-
357
- vid_success = await sync_push_file(temp_vid, hf_vid_path)
358
-
359
- try:
360
- if os.path.exists(temp_vid):
361
- os.remove(temp_vid)
362
- except Exception as e:
363
- logging.warning(f"Cleanup error for {vid}: {e}")
364
-
365
- if not vid_success:
366
- continue
367
-
368
- final_thumb_url = ""
369
  pf = derivatives.get("PosterFrame")
370
- if pf and pf.get("checksum") in assets:
371
- pf_asset = assets[pf["checksum"]]
372
- poster_url = f"https://{pf_asset['url_location']}{pf_asset['url_path']}"
373
-
374
- temp_thumb = await download_to_temp(poster_url, f"{vid}.jpg")
375
- hf_thumb_path = f"videos/{token}/{vid}.jpg"
376
-
377
- thumb_success = await sync_push_file(temp_thumb, hf_thumb_path)
378
-
379
- try:
380
- if os.path.exists(temp_thumb):
381
- os.remove(temp_thumb)
382
- except Exception as e:
383
- logging.warning(f"Cleanup error for thumb {vid}: {e}")
384
-
385
- if thumb_success:
386
- final_thumb_url = get_hf_url(hf_thumb_path)
387
-
388
- new_entries.append({
389
- "id": vid,
390
- "name": photo.get("caption") or "Untitled",
391
- "video_url": get_hf_url(hf_vid_path),
392
- "thumbnail": final_thumb_url,
393
- "upload_date": photo.get("creationDate") or datetime.now(timezone.utc).isoformat(),
394
- "category": category,
395
- "publisher": publisher,
396
- "source_album": token,
397
- "allowed_divs": []
398
  })
399
 
400
- if new_entries:
401
- async with DATA_LOCK:
402
- INDEX_CACHE["videos"].extend(new_entries)
403
- await save_index()
404
- logging.info(f"Added {len(new_entries)} videos from {token}")
405
 
406
  except Exception as e:
407
- logging.error(f"Error polling {token}: {e}")
408
-
409
- # ==================================================
410
- # STARTUP
411
- # ==================================================
412
- @app.on_event("startup")
413
- async def start_polling():
414
- if not HF_TOKEN:
415
- logging.error("CRITICAL: 'hf1' environment variable not set. Dataset storage will fail.")
416
-
417
- await load_state_from_hf()
418
- await backfill_index_categories()
419
-
420
- async def loop():
421
- while True:
422
- tokens = list(ALBUM_PUBLISHERS.keys())
423
- for token in tokens:
424
- await poll_album(token)
425
- await asyncio.sleep(60)
426
-
427
- asyncio.create_task(loop())
428
-
429
- # ==================================================
430
- # FEED (With Div Support)
431
- # ==================================================
432
- @app.get("/feed/videos")
433
- async def get_video_feed(div: Optional[int] = Query(None, description="School Division (1-25)")):
434
- async with DATA_LOCK:
435
- videos = INDEX_CACHE.get("videos", [])
436
-
437
- if div is None:
438
- return {"videos": videos}
439
 
440
- if div not in VALID_DIVISIONS:
441
- return {"videos": [v for v in videos if not v.get("allowed_divs")]}
442
-
443
- filtered_videos = []
444
- for v in videos:
445
- allowed = v.get("allowed_divs", [])
446
- if not allowed or div in allowed:
447
- filtered_videos.append(v)
448
-
449
- return {"videos": filtered_videos}
450
-
451
- # ==================================================
452
- # ADMIN: LOGIN
453
- # ==================================================
454
- @app.get("/admin/login", response_class=HTMLResponse)
455
- async def admin_login_page():
456
- if not admin_enabled():
457
- return HTMLResponse("Admin disabled (ADMIN_KEY missing)", status_code=503)
458
- return """
459
- <html><body style="background:#111;color:#fff;display:flex;justify-content:center;align-items:center;height:100vh;font-family:sans-serif;">
460
- <form method="post" style="padding:20px;border:1px solid #333;border-radius:10px;background:#1a1a1a;">
461
- <h2>Admin</h2><input type="password" name="key" placeholder="Key" style="padding:10px;width:100%;margin-bottom:10px;">
462
- <button style="padding:10px;width:100%;cursor:pointer;">Login</button>
463
- </form></body></html>
464
- """
465
-
466
- @app.post("/admin/login")
467
- async def admin_login(key: str = Form(...)):
468
- if not admin_enabled() or not secure_equals(key.strip(), str(ADMIN_KEY).strip()):
469
- return HTMLResponse("Unauthorized", status_code=401)
470
- session = secrets.token_hex(16)
471
- ADMIN_SESSIONS.add(session)
472
- resp = RedirectResponse("/admin", status_code=302)
473
- resp.set_cookie(ADMIN_COOKIE, session, httponly=True)
474
- return resp
475
-
476
- @app.get("/admin/logout")
477
- async def admin_logout(req: Request):
478
- if (s := req.cookies.get(ADMIN_COOKIE)) in ADMIN_SESSIONS: ADMIN_SESSIONS.remove(s)
479
- return RedirectResponse("/admin/login")
480
-
481
- # ==================================================
482
- # ADMIN DASHBOARD
483
- # ==================================================
484
- def esc(v) -> str: return html_escape_lib.escape("" if v is None else str(v), quote=True)
485
-
486
- ADMIN_TEMPLATE = """
487
- <html>
488
- <head>
489
- <title>Admin</title>
490
- <style>
491
- body{font-family:sans-serif;background:#111;color:#ddd;padding:20px;}
492
- table{width:100%;border-collapse:collapse;margin-top:20px;}
493
- th,td{border-bottom:1px solid #333;padding:8px;text-align:left;}
494
- input{background:#222;border:1px solid #444;color:#fff;padding:5px;border-radius:4px;width:100%;}
495
- button{background:#444;color:#fff;border:none;padding:5px 10px;cursor:pointer;border-radius:4px;}
496
- .pill{padding:4px 8px;background:#004400;border-radius:10px;font-size:0.8em;}
497
- </style>
498
- </head>
499
- <body>
500
- <h1>Admin Panel <a href="/admin/logout" style="font-size:0.5em;color:#888;">Logout</a></h1>
501
-
502
- <h3>Albums</h3>
503
- <table>
504
- <tr><th>Token</th><th>Publisher</th><th>Category</th><th>Action</th></tr>
505
- __ALBUM_ROWS__
506
- <tr>
507
- <td><input id="n_t" placeholder="Token"></td>
508
- <td><input id="n_p" placeholder="Publisher"></td>
509
- <td><input id="n_c" placeholder="Category"></td>
510
- <td><button onclick="addAlbum()">Add</button></td>
511
- </tr>
512
- </table>
513
-
514
- <h3>Videos</h3>
515
- <p style="font-size:0.8em;color:#888;">Allowed Divs: Comma separated (e.g. <code>1,5,25</code>). Leave empty for ALL.</p>
516
- <table>
517
- <tr><th>ID</th><th>Name</th><th>Divs (1-25)</th><th>Category</th><th>Publisher</th><th>Action</th></tr>
518
- __VIDEO_ROWS__
519
- </table>
520
-
521
- <script>
522
- async function api(ep, data) {
523
- await fetch(ep, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(data)});
524
- location.reload();
525
- }
526
- document.querySelectorAll('input[data-id]').forEach(i => {
527
- i.addEventListener('change', (e) => api('/admin/update', {
528
- id: e.target.dataset.id,
529
- field: e.target.dataset.field,
530
- value: e.target.value
531
- }));
532
- });
533
- function addAlbum(){
534
- api('/admin/albums/add', {
535
- token: document.getElementById('n_t').value,
536
- publisher: document.getElementById('n_p').value,
537
- category: document.getElementById('n_c').value
538
- });
539
- }
540
- </script>
541
- </body>
542
- </html>
543
- """
544
-
545
- @app.get("/admin", response_class=HTMLResponse)
546
- async def admin_dash(req: Request):
547
- if not is_admin(req): return RedirectResponse("/admin/login")
548
-
549
- async with DATA_LOCK:
550
- videos = INDEX_CACHE.get("videos", [])
551
-
552
- v_rows = ""
553
- for v in videos:
554
- divs = ",".join(map(str, v.get("allowed_divs", [])))
555
- v_rows += f"""<tr>
556
- <td>{esc(v['id'])[:8]}...</td>
557
- <td><input data-id="{v['id']}" data-field="name" value="{esc(v.get('name'))}"></td>
558
- <td><input data-id="{v['id']}" data-field="allowed_divs" value="{esc(divs)}" placeholder="All"></td>
559
- <td><input data-id="{v['id']}" data-field="category" value="{esc(v.get('category'))}"></td>
560
- <td><input data-id="{v['id']}" data-field="publisher" value="{esc(v.get('publisher'))}"></td>
561
- <td><button onclick="api('/admin/videos/delete', {{id:'{v['id']}'}})" style="background:#500;">Del</button></td>
562
- </tr>"""
563
-
564
- a_rows = ""
565
- for t, p in ALBUM_PUBLISHERS.items():
566
- c = ALBUM_CATEGORIES.get(t, "")
567
- a_rows += f"<tr><td>{t}</td><td>{p}</td><td>{c}</td><td>-</td></tr>"
568
-
569
- return ADMIN_TEMPLATE.replace("__VIDEO_ROWS__", v_rows).replace("__ALBUM_ROWS__", a_rows)
570
-
571
- # ==================================================
572
- # ADMIN ACTIONS
573
- # ==================================================
574
- @app.post("/admin/update")
575
- async def admin_update(req: Request, payload: dict):
576
- if not is_admin(req): return JSONResponse({}, 403)
577
-
578
- vid_id = payload.get("id")
579
- field = payload.get("field")
580
- value = payload.get("value")
581
-
582
- async with DATA_LOCK:
583
- for v in INDEX_CACHE["videos"]:
584
- if v["id"] == vid_id:
585
- if field == "allowed_divs":
586
- try:
587
- if not value.strip():
588
- v[field] = []
589
- else:
590
- nums = [int(x.strip()) for x in value.split(",") if x.strip().isdigit()]
591
- v[field] = [n for n in nums if n in VALID_DIVISIONS]
592
- except:
593
- pass
594
- else:
595
- v[field] = value
596
-
597
- await save_index()
598
- return {"ok": True}
599
- return {"error": "not found"}
600
-
601
- @app.post("/admin/videos/delete")
602
- async def admin_delete(req: Request, payload: dict):
603
- if not is_admin(req): return JSONResponse({}, 403)
604
- vid_id = payload.get("id")
605
-
606
- async with DATA_LOCK:
607
- original_len = len(INDEX_CACHE["videos"])
608
- INDEX_CACHE["videos"] = [v for v in INDEX_CACHE["videos"] if v["id"] != vid_id]
609
-
610
- if len(INDEX_CACHE["videos"]) < original_len:
611
- await save_index()
612
-
613
- return {"ok": True}
614
-
615
- @app.post("/admin/albums/add")
616
- async def admin_album_add(req: Request, payload: dict):
617
- if not is_admin(req): return JSONResponse({}, 403)
618
- t = payload.get("token")
619
- if t:
620
- async with DATA_LOCK:
621
- CONFIG_CACHE["album_publishers"][t] = payload.get("publisher", "Unknown")
622
- CONFIG_CACHE["album_categories"][t] = payload.get("category", "Uncategorized")
623
- global ALBUM_PUBLISHERS, ALBUM_CATEGORIES
624
- ALBUM_PUBLISHERS = CONFIG_CACHE["album_publishers"]
625
- ALBUM_CATEGORIES = CONFIG_CACHE["album_categories"]
626
- await save_config()
627
- return {"ok": True}
 
1
+ from fastapi import FastAPI
 
 
 
 
 
 
 
 
 
 
 
2
  from fastapi.middleware.cors import CORSMiddleware
 
3
  import httpx
4
  import json
5
  import logging
6
+
 
 
 
 
 
 
 
 
 
 
7
  app = FastAPI()
8
  logging.basicConfig(level=logging.INFO)
9
 
10
+ # Enable CORS for all origins
11
  app.add_middleware(
12
  CORSMiddleware,
13
  allow_origins=["*"],
 
15
  allow_headers=["*"],
16
  )
17
 
18
+ BASE_62_MAP = {c: i for i, c in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
 
 
 
20
  async def get_client() -> httpx.AsyncClient:
21
  if not hasattr(app.state, "client"):
22
+ app.state.client = httpx.AsyncClient(timeout=15.0)
23
  return app.state.client
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  def base62_to_int(token: str) -> int:
26
+ result = 0
27
+ for ch in token:
28
+ result = result * 62 + BASE_62_MAP[ch]
29
+ return result
 
 
 
30
 
31
  async def get_base_url(token: str) -> str:
32
+ first = token[0]
33
+ if first == "A":
34
  n = base62_to_int(token[1])
35
  else:
36
  n = base62_to_int(token[1:3])
37
  return f"https://p{n:02d}-sharedstreams.icloud.com/{token}/sharedstreams/"
38
 
39
+ ICLOUD_HEADERS = {
40
+ "Origin": "https://www.icloud.com",
41
+ "Content-Type": "text/plain"
42
+ }
43
+ ICLOUD_PAYLOAD = '{"streamCtag":null}'
44
+
45
  async def get_redirected_base_url(base_url: str, token: str) -> str:
46
  client = await get_client()
47
+ resp = await client.post(
48
+ f"{base_url}webstream", headers=ICLOUD_HEADERS, data=ICLOUD_PAYLOAD, follow_redirects=False
49
+ )
50
+ if resp.status_code == 330:
51
+ try:
52
+ body = resp.json()
53
+ host = body.get("X-Apple-MMe-Host")
54
+ if not host:
55
+ raise ValueError("Missing X-Apple-MMe-Host in 330 response")
56
+ logging.info(f"Redirected to {host}")
57
+ return f"https://{host}/{token}/sharedstreams/"
58
+ except Exception as e:
59
+ logging.error(f"Redirect parsing failed: {e}")
60
+ raise
61
+ elif resp.status_code == 200:
62
  return base_url
63
+ else:
64
+ resp.raise_for_status()
65
 
66
  async def post_json(path: str, base_url: str, payload: str) -> dict:
67
  client = await get_client()
68
+ resp = await client.post(f"{base_url}{path}", headers=ICLOUD_HEADERS, data=payload)
69
+ resp.raise_for_status()
70
+ return resp.json()
71
 
72
  async def get_metadata(base_url: str) -> list:
73
+ data = await post_json("webstream", base_url, ICLOUD_PAYLOAD)
74
+ return data.get("photos", [])
75
 
76
  async def get_asset_urls(base_url: str, guids: list) -> dict:
77
  payload = json.dumps({"photoGuids": guids})
78
+ data = await post_json("webasseturls", base_url, payload)
79
+ return data.get("items", {})
80
 
81
+ @app.get("/album/{token}")
82
+ async def get_album(token: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  try:
84
  base_url = await get_base_url(token)
85
  base_url = await get_redirected_base_url(base_url, token)
86
 
87
  metadata = await get_metadata(base_url)
88
+ guids = [photo["photoGuid"] for photo in metadata]
89
+ asset_map = await get_asset_urls(base_url, guids)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
+ videos = []
92
  for photo in metadata:
93
  if photo.get("mediaAssetType", "").lower() != "video":
94
  continue
95
 
 
 
 
 
96
  derivatives = photo.get("derivatives", {})
97
  best = max(
98
  (d for k, d in derivatives.items() if k.lower() != "posterframe"),
99
  key=lambda d: int(d.get("fileSize") or 0),
100
+ default=None
101
  )
102
+ if not best:
103
  continue
104
 
105
+ checksum = best.get("checksum")
106
+ info = asset_map.get(checksum)
107
+ if not info:
108
+ continue
109
+ video_url = f"https://{info['url_location']}{info['url_path']}"
110
+
111
+ poster = None
 
 
 
 
 
 
 
 
 
 
 
112
  pf = derivatives.get("PosterFrame")
113
+ if pf:
114
+ pf_info = asset_map.get(pf.get("checksum"))
115
+ if pf_info:
116
+ poster = f"https://{pf_info['url_location']}{pf_info['url_path']}"
117
+
118
+ videos.append({
119
+ "caption": photo.get("caption", ""),
120
+ "url": video_url,
121
+ "poster": poster or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  })
123
 
124
+ return {"videos": videos}
 
 
 
 
125
 
126
  except Exception as e:
127
+ logging.exception("Error in get_album")
128
+ return {"error": str(e)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
+ @app.get("/album/{token}/raw")
131
+ async def get_album_raw(token: str):
132
+ try:
133
+ base_url = await get_base_url(token)
134
+ base_url = await get_redirected_base_url(base_url, token)
135
+ metadata = await get_metadata(base_url)
136
+ guids = [photo["photoGuid"] for photo in metadata]
137
+ asset_map = await get_asset_urls(base_url, guids)
138
+ return {"metadata": metadata, "asset_urls": asset_map}
139
+
140
+
141
+
142
+
143
+
144
+
145
+
146
+
147
+
148
+
149
+
150
+
151
+
152
+
153
+
154
+
155
+
156
+
157
+
158
+
159
+ except Exception as e:
160
+ logging.exception("Error in get_album_raw")
161
+ return {"error": str(e)}