SalexAI commited on
Commit
c50185e
·
verified ·
1 Parent(s): 9f1d2f2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +435 -302
app.py CHANGED
@@ -1,7 +1,9 @@
1
- from fastapi import FastAPI, Request
2
  from fastapi.middleware.cors import CORSMiddleware
 
 
3
  from pydantic import BaseModel, Field
4
- from typing import Optional, List, Dict
5
  import httpx
6
  import json
7
  import logging
@@ -11,12 +13,16 @@ import hashlib
11
  from datetime import datetime, timezone
12
  import re
13
  import fastapi
14
- import glob
 
 
 
 
15
 
16
  app = FastAPI()
17
  logging.basicConfig(level=logging.INFO)
18
 
19
- # CORS (HF Spaces + static html)
20
  app.add_middleware(
21
  CORSMiddleware,
22
  allow_origins=["*"],
@@ -25,69 +31,70 @@ app.add_middleware(
25
  )
26
 
27
  # ---------------------------
28
- # 📦 Persistent storage
29
  # ---------------------------
30
  PERSISTENT_ROOT = os.environ.get("PERSISTENT_DIR", "/data")
31
  if not os.path.isdir(PERSISTENT_ROOT):
 
32
  PERSISTENT_ROOT = os.path.join(".", "data")
33
  os.makedirs(PERSISTENT_ROOT, exist_ok=True)
34
 
35
  CHAT_DIR = os.path.join(PERSISTENT_ROOT, "chat")
36
- DM_DIR = os.path.join(PERSISTENT_ROOT, "dm")
37
- USERS_DIR = os.path.join(PERSISTENT_ROOT, "users")
38
  os.makedirs(CHAT_DIR, exist_ok=True)
39
- os.makedirs(DM_DIR, exist_ok=True)
40
- os.makedirs(USERS_DIR, exist_ok=True)
41
 
 
 
42
  USERS_FILE = os.path.join(USERS_DIR, "users.jsonl")
43
  SESSIONS_FILE = os.path.join(USERS_DIR, "sessions.json")
44
- ADMIN_KEY = os.environ.get("ADMIN_KEY", "")
 
 
 
 
 
 
 
 
 
45
 
46
- # Locks + ephemeral state
47
- app.state.file_locks: Dict[str, asyncio.Lock] = {}
48
  app.state.users_lock = asyncio.Lock()
49
  app.state.sessions_lock = asyncio.Lock()
 
50
  app.state.sessions: Dict[str, str] = {}
51
 
52
-
53
- def _room_file(room_id: str) -> str:
54
- h = hashlib.sha256(room_id.encode("utf-8")).hexdigest()[:32]
 
 
55
  return os.path.join(CHAT_DIR, f"{h}.jsonl")
56
 
57
-
58
- def _dm_file(a: str, b: str) -> str:
59
- aa, bb = sorted([a, b])
60
- k = hashlib.sha256(f"{aa}|{bb}".encode("utf-8")).hexdigest()[:40]
61
- return os.path.join(DM_DIR, f"dm_{k}.jsonl")
62
-
63
-
64
  def _lock_for(path: str) -> asyncio.Lock:
65
- lock = app.state.file_locks.get(path)
66
- if not lock:
67
  lock = asyncio.Lock()
68
- app.state.file_locks[path] = lock
69
  return lock
70
 
71
-
72
  def _now_iso() -> str:
73
  return datetime.now(timezone.utc).isoformat()
74
 
 
 
 
 
75
 
76
- def _valid_author(s: Optional[str]) -> str:
77
- s = (s or "anon").strip()
78
- s = re.sub(r"\s+", " ", s)
79
- return s[:32] or "anon"
80
-
81
-
82
- def _valid_text(s: Optional[str]) -> str:
83
- s = (s or "").rstrip("\n")
84
- return s[:5000]
85
-
86
-
87
- def _is_data_url(img: str) -> bool:
88
- return isinstance(img, str) and img.startswith("data:image/") and ";base64," in img
89
 
 
 
90
 
 
 
 
91
  async def _append_jsonl(path: str, record: dict) -> None:
92
  line = json.dumps(record, ensure_ascii=False) + "\n"
93
  def _write():
@@ -95,7 +102,6 @@ async def _append_jsonl(path: str, record: dict) -> None:
95
  f.write(line)
96
  await asyncio.to_thread(_write)
97
 
98
-
99
  async def _read_jsonl(path: str) -> List[dict]:
100
  if not os.path.exists(path):
101
  return []
@@ -105,44 +111,25 @@ async def _read_jsonl(path: str) -> List[dict]:
105
  return await asyncio.to_thread(_read)
106
 
107
  # ---------------------------
108
- # 👤 Users & Auth helpers
109
  # ---------------------------
110
- def _safe_handle(s: str) -> str:
111
- s = (s or "").strip()
112
- s = re.sub(r"[^a-zA-Z0-9_.~-]", "_", s)
113
- return s[:24]
114
-
115
- def _hash_pin(pin: str) -> str:
116
- return hashlib.sha256(("tlks|" + pin).encode("utf-8")).hexdigest()
117
-
118
- def _rand_token() -> str:
119
- return hashlib.sha256(os.urandom(32)).hexdigest()
120
-
121
  async def _append_user(record: dict) -> None:
122
  line = json.dumps(record, ensure_ascii=False) + "\n"
123
- def _write():
124
- with open(USERS_FILE, "a", encoding="utf-8") as f:
125
- f.write(line)
126
  async with app.state.users_lock:
 
 
 
127
  await asyncio.to_thread(_write)
128
 
129
  async def _read_users() -> List[dict]:
130
  if not os.path.exists(USERS_FILE):
131
  return []
132
- def _read():
133
- with open(USERS_FILE, "r", encoding="utf-8") as f:
134
- return [json.loads(x) for x in f if x.strip()]
135
  async with app.state.users_lock:
 
 
 
136
  return await asyncio.to_thread(_read)
137
 
138
- async def _write_users(users: List[dict]):
139
- async with app.state.users_lock:
140
- def _write_all():
141
- with open(USERS_FILE, "w", encoding="utf-8") as f:
142
- for rec in users:
143
- f.write(json.dumps(rec, ensure_ascii=False) + "\n")
144
- await asyncio.to_thread(_write_all)
145
-
146
  async def _load_sessions():
147
  if os.path.exists(SESSIONS_FILE):
148
  def _read():
@@ -160,19 +147,106 @@ async def _save_sessions():
160
  await asyncio.to_thread(_write, app.state.sessions)
161
 
162
  @app.on_event("startup")
163
- async def _startup():
164
  await _load_sessions()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
  def _require_admin(request: Request):
167
- if not ADMIN_KEY or request.headers.get("x-admin-key") != ADMIN_KEY:
 
168
  raise fastapi.HTTPException(status_code=401, detail="Admin key required")
169
 
170
  # ---------------------------
171
- # 💬 Models
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  # ---------------------------
173
  class NewMessage(BaseModel):
174
  text: str = Field(..., min_length=1, max_length=5000)
175
- images: Optional[List[str]] = Field(default=None, description="data:image/...;base64,...", max_items=4)
176
 
177
  class NewUser(BaseModel):
178
  email: str
@@ -192,34 +266,14 @@ class UpdateUser(BaseModel):
192
  description: Optional[str] = None
193
  pin: Optional[str] = None
194
  disabled: Optional[bool] = None
195
- following: Optional[List[str]] = None
196
- followers: Optional[List[str]] = None
197
 
198
  class LoginReq(BaseModel):
199
  handle: str
200
  pin: str
201
 
202
- class MeProfilePatch(BaseModel):
203
- profile_image: Optional[str] = None # can be URL or data URL
204
- description: Optional[str] = None
205
-
206
  # ---------------------------
207
- # 🔐 Auth
208
  # ---------------------------
209
- @app.post("/auth/login")
210
- async def login(req: LoginReq):
211
- users = await _read_users()
212
- handle = _safe_handle(req.handle.lstrip("@"))
213
- u = next((x for x in users if x.get("handle") == handle), None)
214
- if not u or u.get("disabled"):
215
- return {"ok": False, "error": "Invalid user"}
216
- if u.get("pin_hash") != _hash_pin(req.pin):
217
- return {"ok": False, "error": "Invalid PIN"}
218
- token = _rand_token()
219
- app.state.sessions[token] = handle
220
- await _save_sessions()
221
- return {"ok": True, "token": token}
222
-
223
  async def _require_user(request: Request) -> dict:
224
  auth = request.headers.get("authorization", "")
225
  if not auth.lower().startswith("bearer "):
@@ -234,28 +288,8 @@ async def _require_user(request: Request) -> dict:
234
  raise fastapi.HTTPException(status_code=401, detail="User disabled")
235
  return user
236
 
237
- @app.get("/me")
238
- async def me(request: Request):
239
- user = await _require_user(request)
240
- pub = {k: v for k, v in user.items() if k not in ("pin_hash",)}
241
- return {"ok": True, "user": pub}
242
-
243
- @app.put("/me/profile")
244
- async def update_me_profile(patch: MeProfilePatch, request: Request):
245
- me = await _require_user(request)
246
- users = await _read_users()
247
- for u in users:
248
- if u.get("handle") == me["handle"]:
249
- if patch.profile_image is not None:
250
- u["profile_image"] = patch.profile_image.strip()
251
- if patch.description is not None:
252
- u["description"] = patch.description.strip()
253
- break
254
- await _write_users(users)
255
- return {"ok": True}
256
-
257
  # ---------------------------
258
- # 👥 Admin Users
259
  # ---------------------------
260
  @app.post("/admin/users")
261
  async def admin_create_user(user: NewUser, request: Request):
@@ -275,8 +309,8 @@ async def admin_create_user(user: NewUser, request: Request):
275
  "pin_hash": _hash_pin(user.pin),
276
  "disabled": False,
277
  "created_at": _now_iso(),
278
- "following": [],
279
  "followers": [],
 
280
  }
281
  await _append_user(rec)
282
  pub = {k: v for k, v in rec.items() if k != "pin_hash"}
@@ -299,103 +333,214 @@ async def admin_update_user(handle: str, patch: UpdateUser, request: Request):
299
  _require_admin(request)
300
  handle = _safe_handle(handle)
301
  users = await _read_users()
302
- found = False
303
  for u in users:
304
  if u.get("handle") == handle:
305
- data = patch.dict(exclude_unset=True)
306
- if "pin" in data:
307
- u["pin_hash"] = _hash_pin(data.pop("pin"))
308
- for k, v in data.items():
309
- u[k] = v
310
- found = True
 
 
 
 
 
 
 
 
 
311
  break
312
- if not found:
313
  raise fastapi.HTTPException(status_code=404, detail="User not found")
314
- await _write_users(users)
 
 
 
 
 
 
315
  return {"ok": True}
316
 
317
  # ---------------------------
318
- # 🌐 Public Users + Follow
319
  # ---------------------------
320
- @app.get("/users")
321
- async def public_users(q: Optional[str] = None, limit: int = 200):
322
- users = await _read_users()
323
- if q:
324
- ql = q.lower()
325
- users = [u for u in users if ql in u.get("handle", "").lower() or ql in u.get("first_name", "").lower() or ql in u.get("last_name", "").lower()]
326
- users = users[: max(1, min(limit, 1000))]
327
- out = []
328
- for u in users:
329
- if u.get("disabled"):
330
- continue
331
- out.append({
332
- "handle": u["handle"],
333
- "first_name": u["first_name"],
334
- "last_name": u["last_name"],
335
- "klass": u.get("klass", ""),
336
- "profile_image": u.get("profile_image", ""),
337
- "description": u.get("description", ""),
338
- "followers": len(u.get("followers", [])),
339
- "following": len(u.get("following", [])),
340
- })
341
- return {"ok": True, "users": out}
342
-
343
- @app.get("/user/{handle}")
344
- async def get_user_profile(handle: str):
345
  users = await _read_users()
346
- h = _safe_handle(handle)
347
- u = next((x for x in users if x.get("handle") == h and not x.get("disabled")), None)
348
- if not u:
349
- raise fastapi.HTTPException(status_code=404, detail="User not found")
350
- pub = {
351
- "handle": u["handle"],
352
- "first_name": u["first_name"],
353
- "last_name": u["last_name"],
354
- "klass": u.get("klass", ""),
355
- "profile_image": u.get("profile_image", ""),
356
- "description": u.get("description", ""),
357
- "followers": u.get("followers", []),
358
- "following": u.get("following", []),
359
- }
 
360
  return {"ok": True, "user": pub}
361
 
362
- @app.post("/follow/{target}")
363
- async def follow_user(target: str, request: Request):
364
- me = await _require_user(request)
365
- target = _safe_handle(target)
366
- users = await _read_users()
367
- me_u = next((u for u in users if u.get("handle") == me["handle"]), None)
368
- tgt_u = next((u for u in users if u.get("handle") == target and not u.get("disabled")), None)
369
- if not tgt_u:
370
- return {"ok": False, "error": "User not found"}
371
- if target not in me_u.setdefault("following", []):
372
- me_u["following"].append(target)
373
- if me_u["handle"] not in tgt_u.setdefault("followers", []):
374
- tgt_u["followers"].append(me_u["handle"])
375
- await _write_users(users)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  return {"ok": True}
377
 
378
- @app.post("/unfollow/{target}")
379
- async def unfollow_user(target: str, request: Request):
380
- me = await _require_user(request)
381
- target = _safe_handle(target)
382
- users = await _read_users()
383
- me_u = next((u for u in users if u.get("handle") == me["handle"]), None)
384
- tgt_u = next((u for u in users if u.get("handle") == target), None)
385
- if not tgt_u:
386
- return {"ok": False, "error": "User not found"}
387
- me_u["following"] = [h for h in me_u.get("following", []) if h != target]
388
- tgt_u["followers"] = [h for h in tgt_u.get("followers", []) if h != me_u["handle"]]
389
- await _write_users(users)
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  return {"ok": True}
391
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  # ---------------------------
393
- # 💬 Rooms (main/public)
394
  # ---------------------------
395
- @app.get("/chat/{room_id}")
396
- async def get_messages(room_id: str, limit: int = 50, since: Optional[str] = None):
 
 
 
 
397
  limit = max(1, min(limit, 200))
398
- path = _room_file(room_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  items = await _read_jsonl(path)
400
  if since:
401
  try:
@@ -409,126 +554,92 @@ async def get_messages(room_id: str, limit: int = 50, since: Optional[str] = Non
409
  items.sort(key=lambda m: m.get("created_at", ""))
410
  if len(items) > limit:
411
  items = items[-limit:]
412
- return {"room_id": room_id, "count": len(items), "messages": items}
413
-
414
- @app.post("/chat/{room_id}")
415
- async def post_message(room_id: str, msg: NewMessage, request: Request):
 
 
 
 
 
 
 
 
 
 
 
416
  user = await _require_user(request)
417
- path = _room_file(room_id)
418
- lock = _lock_for(path)
419
-
420
- text = _valid_text(msg.text)
421
- if not text:
422
  return {"ok": False, "error": "Empty message"}
423
-
424
- images = msg.images or []
425
- # accept only data URLs (already base64 from client)
426
- images = [img for img in images if _is_data_url(img)][:4]
427
-
428
- author = _valid_author((f"{user.get('first_name','')} {user.get('last_name','')}".strip()) or user["handle"])
 
 
 
 
 
 
 
 
429
  created = _now_iso()
430
- mid = hashlib.sha1(f"{room_id}|{author}|{created}|{text}".encode("utf-8")).hexdigest()[:16]
431
- record = {
 
432
  "id": mid,
433
- "room_id": room_id,
434
  "author": author,
435
- "handle": user["handle"],
436
  "klass": user.get("klass", ""),
437
  "profile_image": user.get("profile_image", ""),
438
  "text": text,
439
- "images": images,
440
  "created_at": created,
 
 
441
  }
442
- async with lock:
443
- await _append_jsonl(path, record)
444
- return {"ok": True, "message": record}
445
-
446
- # ---------------------------
447
- # ✉️ DMs
448
- # ---------------------------
449
- @app.get("/dm/{target}")
450
- async def dm_get(target: str, request: Request, limit: int = 50, since: Optional[str] = None):
451
- me = await _require_user(request)
452
- target = _safe_handle(target)
453
- users = await _read_users()
454
- # You must follow to DM
455
- me_u = next((u for u in users if u["handle"] == me["handle"]), None)
456
- if target not in me_u.get("following", []):
457
- raise fastapi.HTTPException(status_code=403, detail="Follow user first to DM")
458
-
459
- path = _dm_file(me["handle"], target)
460
- items = await _read_jsonl(path)
461
- if since:
462
- try:
463
- since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
464
- items = [
465
- m for m in items
466
- if datetime.fromisoformat(str(m.get("created_at", "")).replace("Z", "+00:00")) > since_dt
467
- ]
468
- except Exception:
469
- pass
470
- items.sort(key=lambda m: m.get("created_at", ""))
471
- if len(items) > limit:
472
- items = items[-limit:]
473
- return {"with": target, "count": len(items), "messages": items}
474
-
475
- @app.post("/dm/{target}")
476
- async def dm_post(target: str, msg: NewMessage, request: Request):
477
- me = await _require_user(request)
478
- target = _safe_handle(target)
479
- users = await _read_users()
480
- # must follow to DM
481
- me_u = next((u for u in users if u["handle"] == me["handle"]), None)
482
- if target not in me_u.get("following", []):
483
- raise fastapi.HTTPException(status_code=403, detail="Follow user first to DM")
484
- tgt_u = next((u for u in users if u.get("handle") == target and not u.get("disabled")), None)
485
- if not tgt_u:
486
- raise fastapi.HTTPException(status_code=404, detail="User not found")
487
-
488
- text = _valid_text(msg.text)
489
- if not text:
490
- return {"ok": False, "error": "Empty message"}
491
-
492
- images = [img for img in (msg.images or []) if _is_data_url(img)][:4]
493
-
494
- path = _dm_file(me["handle"], target)
495
  lock = _lock_for(path)
496
-
497
- created = _now_iso()
498
- mid = hashlib.sha1(f"{me['handle']}->{target}|{created}|{text}".encode("utf-8")).hexdigest()[:16]
499
- rec = {
500
- "id": mid,
501
- "from": me["handle"],
502
- "to": target,
503
- "text": text,
504
- "images": images,
505
- "created_at": created,
506
- }
507
  async with lock:
508
  await _append_jsonl(path, rec)
509
  return {"ok": True, "message": rec}
510
 
511
  # ---------------------------
512
- # 👤 Last N posts for a user (for profile popup)
513
  # ---------------------------
514
- def _iter_room_files() -> List[str]:
515
- return glob.glob(os.path.join(CHAT_DIR, "*.jsonl"))
516
-
517
- @app.get("/user/{handle}/posts")
518
- async def user_posts(handle: str, limit: int = 5):
519
- handle = _safe_handle(handle)
520
- results: List[dict] = []
521
- for path in _iter_room_files():
522
- msgs = await _read_jsonl(path)
523
- for m in msgs:
524
- if m.get("handle") == handle:
525
- results.append(m)
526
- results.sort(key=lambda m: m.get("created_at", ""), reverse=True)
527
- results = results[: max(1, min(limit, 20))]
528
- return {"ok": True, "posts": results}
 
 
 
 
 
 
 
 
529
 
530
  # ---------------------------
531
- # (Original iCloud album endpoints) unchanged
532
  # ---------------------------
533
  BASE_62_MAP = {c: i for i, c in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")}
534
 
@@ -551,18 +662,28 @@ async def get_base_url(token: str) -> str:
551
  n = base62_to_int(token[1:3])
552
  return f"https://p{n:02d}-sharedstreams.icloud.com/{token}/sharedstreams/"
553
 
554
- ICLOUD_HEADERS = {"Origin": "https://www.icloud.com", "Content-Type": "text/plain"}
 
 
 
555
  ICLOUD_PAYLOAD = '{"streamCtag":null}'
556
 
557
  async def get_redirected_base_url(base_url: str, token: str) -> str:
558
  client = await get_client()
559
- resp = await client.post(f"{base_url}webstream", headers=ICLOUD_HEADERS, data=ICLOUD_PAYLOAD, follow_redirects=False)
 
 
560
  if resp.status_code == 330:
561
- body = resp.json()
562
- host = body.get("X-Apple-MMe-Host")
563
- if not host:
564
- raise ValueError("Missing X-Apple-MMe-Host in 330 response")
565
- return f"https://{host}/{token}/sharedstreams/"
 
 
 
 
 
566
  elif resp.status_code == 200:
567
  return base_url
568
  else:
@@ -588,13 +709,16 @@ async def get_album(token: str):
588
  try:
589
  base_url = await get_base_url(token)
590
  base_url = await get_redirected_base_url(base_url, token)
 
591
  metadata = await get_metadata(base_url)
592
  guids = [photo["photoGuid"] for photo in metadata]
593
  asset_map = await get_asset_urls(base_url, guids)
 
594
  videos = []
595
  for photo in metadata:
596
  if photo.get("mediaAssetType", "").lower() != "video":
597
  continue
 
598
  derivatives = photo.get("derivatives", {})
599
  best = max(
600
  (d for k, d in derivatives.items() if k.lower() != "posterframe"),
@@ -603,19 +727,28 @@ async def get_album(token: str):
603
  )
604
  if not best:
605
  continue
 
606
  checksum = best.get("checksum")
607
  info = asset_map.get(checksum)
608
  if not info:
609
  continue
610
  video_url = f"https://{info['url_location']}{info['url_path']}"
611
- poster = ""
 
612
  pf = derivatives.get("PosterFrame")
613
  if pf:
614
  pf_info = asset_map.get(pf.get("checksum"))
615
  if pf_info:
616
  poster = f"https://{pf_info['url_location']}{pf_info['url_path']}"
617
- videos.append({"caption": photo.get("caption", ""), "url": video_url, "poster": poster})
 
 
 
 
 
 
618
  return {"videos": videos}
 
619
  except Exception as e:
620
  logging.exception("Error in get_album")
621
  return {"error": str(e)}
 
1
+ from fastapi import FastAPI, Request, UploadFile, File, Form
2
  from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.staticfiles import StaticFiles
4
+ from fastapi.responses import FileResponse
5
  from pydantic import BaseModel, Field
6
+ from typing import Optional, List, Dict, Any
7
  import httpx
8
  import json
9
  import logging
 
13
  from datetime import datetime, timezone
14
  import re
15
  import fastapi
16
+ import base64
17
+ import imghdr
18
+ import mimetypes
19
+ import pathlib
20
+ import uuid
21
 
22
  app = FastAPI()
23
  logging.basicConfig(level=logging.INFO)
24
 
25
+ # Enable CORS for convenience (adjust origins for production)
26
  app.add_middleware(
27
  CORSMiddleware,
28
  allow_origins=["*"],
 
31
  )
32
 
33
  # ---------------------------
34
+ # Persistent storage setup
35
  # ---------------------------
36
  PERSISTENT_ROOT = os.environ.get("PERSISTENT_DIR", "/data")
37
  if not os.path.isdir(PERSISTENT_ROOT):
38
+ # fallback to local data folder for dev
39
  PERSISTENT_ROOT = os.path.join(".", "data")
40
  os.makedirs(PERSISTENT_ROOT, exist_ok=True)
41
 
42
  CHAT_DIR = os.path.join(PERSISTENT_ROOT, "chat")
 
 
43
  os.makedirs(CHAT_DIR, exist_ok=True)
 
 
44
 
45
+ USERS_DIR = os.path.join(PERSISTENT_ROOT, "users")
46
+ os.makedirs(USERS_DIR, exist_ok=True)
47
  USERS_FILE = os.path.join(USERS_DIR, "users.jsonl")
48
  SESSIONS_FILE = os.path.join(USERS_DIR, "sessions.json")
49
+ ROOMS_FILE = os.path.join(USERS_DIR, "rooms.json") # rooms persisted here
50
+
51
+ STATIC_DIR = os.path.join(PERSISTENT_ROOT, "static")
52
+ IMAGES_DIR = os.path.join(STATIC_DIR, "images")
53
+ os.makedirs(IMAGES_DIR, exist_ok=True)
54
+
55
+ ADMIN_KEY = os.environ.get("ADMIN_KEY", "") # set as secret in HF Space
56
+
57
+ # Mount static files (serves uploaded images at /static/images/...)
58
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
59
 
60
+ # Async locks
61
+ app.state.chat_locks = {}
62
  app.state.users_lock = asyncio.Lock()
63
  app.state.sessions_lock = asyncio.Lock()
64
+ app.state.rooms_lock = asyncio.Lock()
65
  app.state.sessions: Dict[str, str] = {}
66
 
67
+ # ---------------------------
68
+ # Helpers: filenames, locks, time
69
+ # ---------------------------
70
+ def _chat_file_for(room_code: str) -> str:
71
+ h = hashlib.sha256(room_code.encode("utf-8")).hexdigest()[:32]
72
  return os.path.join(CHAT_DIR, f"{h}.jsonl")
73
 
 
 
 
 
 
 
 
74
  def _lock_for(path: str) -> asyncio.Lock:
75
+ lock = app.state.chat_locks.get(path)
76
+ if lock is None:
77
  lock = asyncio.Lock()
78
+ app.state.chat_locks[path] = lock
79
  return lock
80
 
 
81
  def _now_iso() -> str:
82
  return datetime.now(timezone.utc).isoformat()
83
 
84
+ def _safe_handle(s: str) -> str:
85
+ s = (s or "").strip()
86
+ s = re.sub(r"[^a-zA-Z0-9_.~-]", "_", s)
87
+ return s[:24]
88
 
89
+ def _hash_pin(pin: str) -> str:
90
+ return hashlib.sha256(("tlks|" + pin).encode("utf-8")).hexdigest()
 
 
 
 
 
 
 
 
 
 
 
91
 
92
+ def _rand_token() -> str:
93
+ return hashlib.sha256(os.urandom(32)).hexdigest()
94
 
95
+ # ---------------------------
96
+ # JSONL read/write helpers
97
+ # ---------------------------
98
  async def _append_jsonl(path: str, record: dict) -> None:
99
  line = json.dumps(record, ensure_ascii=False) + "\n"
100
  def _write():
 
102
  f.write(line)
103
  await asyncio.to_thread(_write)
104
 
 
105
  async def _read_jsonl(path: str) -> List[dict]:
106
  if not os.path.exists(path):
107
  return []
 
111
  return await asyncio.to_thread(_read)
112
 
113
  # ---------------------------
114
+ # Users & sessions
115
  # ---------------------------
 
 
 
 
 
 
 
 
 
 
 
116
  async def _append_user(record: dict) -> None:
117
  line = json.dumps(record, ensure_ascii=False) + "\n"
 
 
 
118
  async with app.state.users_lock:
119
+ def _write():
120
+ with open(USERS_FILE, "a", encoding="utf-8") as f:
121
+ f.write(line)
122
  await asyncio.to_thread(_write)
123
 
124
  async def _read_users() -> List[dict]:
125
  if not os.path.exists(USERS_FILE):
126
  return []
 
 
 
127
  async with app.state.users_lock:
128
+ def _read():
129
+ with open(USERS_FILE, "r", encoding="utf-8") as f:
130
+ return [json.loads(x) for x in f if x.strip()]
131
  return await asyncio.to_thread(_read)
132
 
 
 
 
 
 
 
 
 
133
  async def _load_sessions():
134
  if os.path.exists(SESSIONS_FILE):
135
  def _read():
 
147
  await asyncio.to_thread(_write, app.state.sessions)
148
 
149
  @app.on_event("startup")
150
+ async def _startup_load_sessions():
151
  await _load_sessions()
152
+ # ensure ROOMS_FILE exists with a default public 'main' room
153
+ async with app.state.rooms_lock:
154
+ if not os.path.exists(ROOMS_FILE):
155
+ default = [
156
+ {"code": "main", "name": "General", "private": False, "invite_code": "", "members": []}
157
+ ]
158
+ def _write():
159
+ with open(ROOMS_FILE, "w", encoding="utf-8") as f:
160
+ json.dump(default, f)
161
+ await asyncio.to_thread(_write)
162
+
163
+ # ---------------------------
164
+ # Room persistence helpers
165
+ # ---------------------------
166
+ async def _read_rooms() -> List[dict]:
167
+ async with app.state.rooms_lock:
168
+ if not os.path.exists(ROOMS_FILE):
169
+ return []
170
+ def _read():
171
+ with open(ROOMS_FILE, "r", encoding="utf-8") as f:
172
+ return json.load(f)
173
+ return await asyncio.to_thread(_read)
174
+
175
+ async def _save_rooms(rooms: List[dict]) -> None:
176
+ async with app.state.rooms_lock:
177
+ def _write(r):
178
+ with open(ROOMS_FILE, "w", encoding="utf-8") as f:
179
+ json.dump(r, f, ensure_ascii=False, indent=2)
180
+ await asyncio.to_thread(_write, rooms)
181
 
182
  def _require_admin(request: Request):
183
+ provided = request.headers.get("x-admin-key", "")
184
+ if not ADMIN_KEY or provided != ADMIN_KEY:
185
  raise fastapi.HTTPException(status_code=401, detail="Admin key required")
186
 
187
  # ---------------------------
188
+ # Image helpers (save data-URL or UploadFile)
189
+ # ---------------------------
190
+ def _guess_ext_from_mime(mime: str) -> str:
191
+ ext = mimetypes.guess_extension(mime.split(";")[0].strip()) or ""
192
+ if ext == ".jpe":
193
+ ext = ".jpg"
194
+ return ext
195
+
196
+ def _safe_image_filename(data_bytes: bytes, ext: str) -> str:
197
+ h = hashlib.sha256(data_bytes).hexdigest()[:20]
198
+ filename = f"{h}{ext or ''}"
199
+ return filename
200
+
201
+ async def save_data_url(data_url: str) -> str:
202
+ """
203
+ Accept data URL (data:<mime>;base64,<data>) and save to IMAGES_DIR.
204
+ Returns path relative to /static (e.g. /static/images/<file>).
205
+ """
206
+ if not data_url.startswith("data:"):
207
+ raise ValueError("Not a data URL")
208
+ header, b64 = data_url.split(",", 1)
209
+ # header e.g. "data:image/png;base64"
210
+ try:
211
+ payload = base64.b64decode(b64)
212
+ except Exception as e:
213
+ raise ValueError("Bad base64") from e
214
+
215
+ mime_match = header.split(";")[0].split(":", 1)[-1] if ";" in header else header.split(":", 1)[-1]
216
+ ext = _guess_ext_from_mime(mime_match) or (("." + imghdr.what(None, payload)) if imghdr.what(None, payload) else ".jpg")
217
+ fname = _safe_image_filename(payload, ext)
218
+ out_path = os.path.join(IMAGES_DIR, fname)
219
+ # save
220
+ def _write():
221
+ with open(out_path, "wb") as f:
222
+ f.write(payload)
223
+ await asyncio.to_thread(_write)
224
+ return f"/static/images/{fname}"
225
+
226
+ async def save_upload_file(upload: UploadFile) -> str:
227
+ contents = await upload.read()
228
+ ext = ""
229
+ # try from content-type
230
+ if upload.content_type:
231
+ ext = _guess_ext_from_mime(upload.content_type)
232
+ if not ext:
233
+ # fallback to imghdr to detect
234
+ kind = imghdr.what(None, contents)
235
+ ext = f".{kind}" if kind else ".jpg"
236
+ fname = _safe_image_filename(contents, ext)
237
+ out_path = os.path.join(IMAGES_DIR, fname)
238
+ def _write():
239
+ with open(out_path, "wb") as f:
240
+ f.write(contents)
241
+ await asyncio.to_thread(_write)
242
+ return f"/static/images/{fname}"
243
+
244
+ # ---------------------------
245
+ # Models
246
  # ---------------------------
247
  class NewMessage(BaseModel):
248
  text: str = Field(..., min_length=1, max_length=5000)
249
+ images: Optional[List[str]] = None # can be data-URLs or already-servable URLs
250
 
251
  class NewUser(BaseModel):
252
  email: str
 
266
  description: Optional[str] = None
267
  pin: Optional[str] = None
268
  disabled: Optional[bool] = None
 
 
269
 
270
  class LoginReq(BaseModel):
271
  handle: str
272
  pin: str
273
 
 
 
 
 
274
  # ---------------------------
275
+ # Auth utilities
276
  # ---------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  async def _require_user(request: Request) -> dict:
278
  auth = request.headers.get("authorization", "")
279
  if not auth.lower().startswith("bearer "):
 
288
  raise fastapi.HTTPException(status_code=401, detail="User disabled")
289
  return user
290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  # ---------------------------
292
+ # Admin: user creation (existing) and room management
293
  # ---------------------------
294
  @app.post("/admin/users")
295
  async def admin_create_user(user: NewUser, request: Request):
 
309
  "pin_hash": _hash_pin(user.pin),
310
  "disabled": False,
311
  "created_at": _now_iso(),
 
312
  "followers": [],
313
+ "following": [],
314
  }
315
  await _append_user(rec)
316
  pub = {k: v for k, v in rec.items() if k != "pin_hash"}
 
333
  _require_admin(request)
334
  handle = _safe_handle(handle)
335
  users = await _read_users()
336
+ changed = False
337
  for u in users:
338
  if u.get("handle") == handle:
339
+ if patch.first_name is not None:
340
+ u["first_name"] = patch.first_name.strip()
341
+ if patch.last_name is not None:
342
+ u["last_name"] = patch.last_name.strip()
343
+ if patch.klass is not None:
344
+ u["klass"] = patch.klass.strip()
345
+ if patch.profile_image is not None:
346
+ u["profile_image"] = patch.profile_image.strip()
347
+ if patch.description is not None:
348
+ u["description"] = patch.description.strip()
349
+ if patch.disabled is not None:
350
+ u["disabled"] = bool(patch.disabled)
351
+ if patch.pin is not None:
352
+ u["pin_hash"] = _hash_pin(patch.pin)
353
+ changed = True
354
  break
355
+ if not changed:
356
  raise fastapi.HTTPException(status_code=404, detail="User not found")
357
+ # rewrite users file
358
+ async with app.state.users_lock:
359
+ def _write_all():
360
+ with open(USERS_FILE, "w", encoding="utf-8") as f:
361
+ for rec in users:
362
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
363
+ await asyncio.to_thread(_write_all)
364
  return {"ok": True}
365
 
366
  # ---------------------------
367
+ # Auth: login
368
  # ---------------------------
369
+ @app.post("/auth/login")
370
+ async def login(req: LoginReq):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  users = await _read_users()
372
+ handle = _safe_handle(req.handle.lstrip("@"))
373
+ u = next((x for x in users if x.get("handle") == handle), None)
374
+ if not u or u.get("disabled"):
375
+ return {"ok": False, "error": "Invalid user"}
376
+ if u.get("pin_hash") != _hash_pin(req.pin):
377
+ return {"ok": False, "error": "Invalid PIN"}
378
+ token = _rand_token()
379
+ app.state.sessions[token] = handle
380
+ await _save_sessions()
381
+ return {"ok": True, "token": token}
382
+
383
+ @app.get("/me")
384
+ async def me(request: Request):
385
+ user = await _require_user(request)
386
+ pub = {k: v for k, v in user.items() if k not in ("pin_hash",)}
387
  return {"ok": True, "user": pub}
388
 
389
+ # ---------------------------
390
+ # Rooms endpoints
391
+ # ---------------------------
392
+ @app.get("/rooms")
393
+ async def list_rooms(request: Request = None):
394
+ """
395
+ Returns rooms. If an Authorization bearer is supplied and the user is member/admin,
396
+ private room info will be visible. Otherwise invite_code is hidden.
397
+ """
398
+ rooms = await _read_rooms()
399
+ # If a valid bearer token present, get handle
400
+ authed_handle = None
401
+ auth = None
402
+ if request:
403
+ auth = request.headers.get("authorization", "")
404
+ if auth and auth.lower().startswith("bearer "):
405
+ token = auth.split(" ", 1)[1].strip()
406
+ authed_handle = app.state.sessions.get(token)
407
+ out = []
408
+ for r in rooms:
409
+ copy = {k: v for k, v in r.items() if k != "invite_code"}
410
+ # show invite_code only to admin (x-admin-key) or to admin bearer user (not implemented)
411
+ if request and request.headers.get("x-admin-key", "") == ADMIN_KEY:
412
+ copy["invite_code"] = r.get("invite_code", "")
413
+ # mark if current user is a member
414
+ copy["member"] = authed_handle in (r.get("members") or [])
415
+ out.append(copy)
416
+ return {"ok": True, "rooms": out}
417
+
418
+ @app.post("/admin/rooms")
419
+ async def admin_create_room(payload: dict, request: Request):
420
+ """
421
+ Admin-only: create room:
422
+ { "code": "grade6", "name": "Grade 6", "private": true }
423
+ Returns invite_code (server-generated) for private rooms.
424
+ """
425
+ _require_admin(request)
426
+ code = _safe_handle((payload.get("code") or "").lower())
427
+ if not code:
428
+ raise fastapi.HTTPException(status_code=400, detail="Invalid code")
429
+ name = (payload.get("name") or code).strip()
430
+ private = bool(payload.get("private", False))
431
+ rooms = await _read_rooms()
432
+ if any(r.get("code") == code for r in rooms):
433
+ return {"ok": False, "error": "Room exists"}
434
+ invite_code = ""
435
+ if private:
436
+ invite_code = hashlib.sha1(os.urandom(16)).hexdigest()[:10].upper()
437
+ rec = {"code": code, "name": name, "private": private, "invite_code": invite_code, "members": []}
438
+ rooms.append(rec)
439
+ await _save_rooms(rooms)
440
+ return {"ok": True, "room": rec}
441
+
442
+ @app.get("/admin/rooms")
443
+ async def admin_list_rooms(request: Request):
444
+ _require_admin(request)
445
+ rooms = await _read_rooms()
446
+ return {"ok": True, "rooms": rooms}
447
+
448
+ @app.get("/admin/rooms/{code}")
449
+ async def admin_get_room(code: str, request: Request):
450
+ _require_admin(request)
451
+ rooms = await _read_rooms()
452
+ r = next((x for x in rooms if x.get("code") == code), None)
453
+ if not r:
454
+ raise fastapi.HTTPException(status_code=404, detail="Room not found")
455
+ return {"ok": True, "room": r}
456
+
457
+ @app.delete("/admin/rooms/{code}")
458
+ async def admin_delete_room(code: str, request: Request):
459
+ _require_admin(request)
460
+ rooms = await _read_rooms()
461
+ code = _safe_handle(code)
462
+ new = [r for r in rooms if r.get("code") != code]
463
+ await _save_rooms(new)
464
  return {"ok": True}
465
 
466
+ @app.get("/admin/rooms/{code}/members")
467
+ async def admin_room_members(code: str, request: Request):
468
+ _require_admin(request)
469
+ rooms = await _read_rooms()
470
+ r = next((x for x in rooms if x.get("code") == code), None)
471
+ if not r: raise fastapi.HTTPException(status_code=404, detail="Room not found")
472
+ return {"ok": True, "members": r.get("members", [])}
473
+
474
+ @app.post("/admin/rooms/{code}/members/remove")
475
+ async def admin_remove_room_member(code: str, body: dict, request: Request):
476
+ _require_admin(request)
477
+ handle = _safe_handle(body.get("handle", ""))
478
+ rooms = await _read_rooms()
479
+ found = False
480
+ for r in rooms:
481
+ if r.get("code") == code:
482
+ members = r.get("members", [])
483
+ if handle in members:
484
+ members.remove(handle)
485
+ r["members"] = members
486
+ found = True
487
+ break
488
+ if not found:
489
+ return {"ok": False, "error": "Member not found in room"}
490
+ await _save_rooms(rooms)
491
  return {"ok": True}
492
 
493
+ @app.post("/rooms/{code}/join")
494
+ async def join_room(code: str, body: dict, request: Request):
495
+ """
496
+ Authenticated users call to join private rooms using invite code.
497
+ Non-private rooms are joinable without invite (server adds user as member).
498
+ """
499
+ user = await _require_user(request)
500
+ code = _safe_handle(code)
501
+ rooms = await _read_rooms()
502
+ r = next((x for x in rooms if x.get("code") == code), None)
503
+ if not r:
504
+ raise fastapi.HTTPException(status_code=404, detail="Room not found")
505
+ if r.get("private"):
506
+ provided = (body.get("invite") or "").strip()
507
+ if not provided:
508
+ raise fastapi.HTTPException(status_code=400, detail="Invite required")
509
+ if provided != r.get("invite_code"):
510
+ raise fastapi.HTTPException(status_code=403, detail="Invalid invite code")
511
+ # add member if not already
512
+ members = set(r.get("members", []))
513
+ members.add(user["handle"])
514
+ r["members"] = list(members)
515
+ await _save_rooms(rooms)
516
+ return {"ok": True, "room": r}
517
+
518
  # ---------------------------
519
+ # Chat endpoints (read + write) with privacy enforcement
520
  # ---------------------------
521
+ @app.get("/chat/{room_code}")
522
+ async def get_messages(room_code: str, limit: int = 50, since: Optional[str] = None, request: Request = None):
523
+ """
524
+ Fetch messages for a room.
525
+ Public rooms are readable by anyone; private rooms require membership/auth.
526
+ """
527
  limit = max(1, min(limit, 200))
528
+ rooms = await _read_rooms()
529
+ room_code = _safe_handle(room_code)
530
+ room = next((r for r in rooms if r.get("code") == room_code), None)
531
+ if not room:
532
+ # allow fallback to legacy behavior if no room metadata: public
533
+ room = {"code": room_code, "private": False}
534
+ # if private, require membership
535
+ if room.get("private"):
536
+ # require bearer token
537
+ try:
538
+ user = await _require_user(request)
539
+ except Exception:
540
+ raise fastapi.HTTPException(status_code=401, detail="Authentication required for this room")
541
+ if user.get("handle") not in room.get("members", []):
542
+ raise fastapi.HTTPException(status_code=403, detail="Not a member of this room")
543
+ path = _chat_file_for(room_code)
544
  items = await _read_jsonl(path)
545
  if since:
546
  try:
 
554
  items.sort(key=lambda m: m.get("created_at", ""))
555
  if len(items) > limit:
556
  items = items[-limit:]
557
+ return {"room": room_code, "count": len(items), "messages": items}
558
+
559
+ @app.post("/chat/{room_code}")
560
+ async def post_message(room_code: str, msg: NewMessage, request: Request):
561
+ """
562
+ Append a message to a room's chat.
563
+ Accepts images (data-urls or public URLs). Data-urls will be saved server-side and replaced with /static URLs.
564
+ """
565
+ room_code = _safe_handle(room_code)
566
+ rooms = await _read_rooms()
567
+ room = next((r for r in rooms if r.get("code") == room_code), None)
568
+ if not room:
569
+ # if room doesn't exist, disallow posting
570
+ raise fastapi.HTTPException(status_code=404, detail="Room not found")
571
+ # require auth
572
  user = await _require_user(request)
573
+ if room.get("private") and user.get("handle") not in room.get("members", []):
574
+ raise fastapi.HTTPException(status_code=403, detail="Not a member of this room")
575
+ text = (msg.text or "").strip()
576
+ if not text and not msg.images:
 
577
  return {"ok": False, "error": "Empty message"}
578
+ images_out: List[str] = []
579
+ if msg.images:
580
+ # images can be data-urls or already public URLs; save data-urls
581
+ for im in msg.images[:8]:
582
+ try:
583
+ if isinstance(im, str) and im.startswith("data:"):
584
+ url = await save_data_url(im)
585
+ images_out.append(url)
586
+ else:
587
+ # treat as already a served URL (validate basic)
588
+ images_out.append(im)
589
+ except Exception as e:
590
+ logging.exception("image save failed")
591
+ author = f"{user.get('first_name','')} {user.get('last_name','')}".strip() or user.get("handle")
592
  created = _now_iso()
593
+ mid = hashlib.sha1(f"{room_code}|{author}|{created}|{text}".encode("utf-8")).hexdigest()[:16]
594
+ ip = request.client.host if request and request.client else None
595
+ rec = {
596
  "id": mid,
597
+ "room": room_code,
598
  "author": author,
599
+ "handle": user.get("handle"),
600
  "klass": user.get("klass", ""),
601
  "profile_image": user.get("profile_image", ""),
602
  "text": text,
603
+ "images": images_out,
604
  "created_at": created,
605
+ "ip": ip,
606
+ "ua": request.headers.get("user-agent", "")[:200],
607
  }
608
+ path = _chat_file_for(room_code)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  lock = _lock_for(path)
 
 
 
 
 
 
 
 
 
 
 
610
  async with lock:
611
  await _append_jsonl(path, rec)
612
  return {"ok": True, "message": rec}
613
 
614
  # ---------------------------
615
+ # Upload endpoint (multipart or JSON data-url)
616
  # ---------------------------
617
+ @app.post("/upload_image")
618
+ async def upload_image(file: Optional[UploadFile] = File(None), data_url: Optional[str] = Form(None), request: Request = None):
619
+ """
620
+ Accepts:
621
+ - multipart form 'file' (UploadFile)
622
+ - OR form field 'data_url' with a base64 data:... URL
623
+ Returns JSON: { ok: True, url: "/static/images/<file>" }
624
+ """
625
+ if file:
626
+ try:
627
+ url = await save_upload_file(file)
628
+ return {"ok": True, "url": url}
629
+ except Exception as e:
630
+ logging.exception("upload failed")
631
+ raise fastapi.HTTPException(status_code=400, detail="Upload failed")
632
+ if data_url:
633
+ try:
634
+ url = await save_data_url(data_url)
635
+ return {"ok": True, "url": url}
636
+ except Exception as e:
637
+ logging.exception("data-url save failed")
638
+ raise fastapi.HTTPException(status_code=400, detail="Bad data URL")
639
+ raise fastapi.HTTPException(status_code=400, detail="No file or data_url provided")
640
 
641
  # ---------------------------
642
+ # (leftover original icloud endpoints unchanged)
643
  # ---------------------------
644
  BASE_62_MAP = {c: i for i, c in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")}
645
 
 
662
  n = base62_to_int(token[1:3])
663
  return f"https://p{n:02d}-sharedstreams.icloud.com/{token}/sharedstreams/"
664
 
665
+ ICLOUD_HEADERS = {
666
+ "Origin": "https://www.icloud.com",
667
+ "Content-Type": "text/plain",
668
+ }
669
  ICLOUD_PAYLOAD = '{"streamCtag":null}'
670
 
671
  async def get_redirected_base_url(base_url: str, token: str) -> str:
672
  client = await get_client()
673
+ resp = await client.post(
674
+ f"{base_url}webstream", headers=ICLOUD_HEADERS, data=ICLOUD_PAYLOAD, follow_redirects=False
675
+ )
676
  if resp.status_code == 330:
677
+ try:
678
+ body = resp.json()
679
+ host = body.get("X-Apple-MMe-Host")
680
+ if not host:
681
+ raise ValueError("Missing X-Apple-MMe-Host in 330 response")
682
+ logging.info(f"Redirected to {host}")
683
+ return f"https://{host}/{token}/sharedstreams/"
684
+ except Exception as e:
685
+ logging.error(f"Redirect parsing failed: {e}")
686
+ raise
687
  elif resp.status_code == 200:
688
  return base_url
689
  else:
 
709
  try:
710
  base_url = await get_base_url(token)
711
  base_url = await get_redirected_base_url(base_url, token)
712
+
713
  metadata = await get_metadata(base_url)
714
  guids = [photo["photoGuid"] for photo in metadata]
715
  asset_map = await get_asset_urls(base_url, guids)
716
+
717
  videos = []
718
  for photo in metadata:
719
  if photo.get("mediaAssetType", "").lower() != "video":
720
  continue
721
+
722
  derivatives = photo.get("derivatives", {})
723
  best = max(
724
  (d for k, d in derivatives.items() if k.lower() != "posterframe"),
 
727
  )
728
  if not best:
729
  continue
730
+
731
  checksum = best.get("checksum")
732
  info = asset_map.get(checksum)
733
  if not info:
734
  continue
735
  video_url = f"https://{info['url_location']}{info['url_path']}"
736
+
737
+ poster = None
738
  pf = derivatives.get("PosterFrame")
739
  if pf:
740
  pf_info = asset_map.get(pf.get("checksum"))
741
  if pf_info:
742
  poster = f"https://{pf_info['url_location']}{pf_info['url_path']}"
743
+
744
+ videos.append({
745
+ "caption": photo.get("caption", ""),
746
+ "url": video_url,
747
+ "poster": poster or "",
748
+ })
749
+
750
  return {"videos": videos}
751
+
752
  except Exception as e:
753
  logging.exception("Error in get_album")
754
  return {"error": str(e)}