SalexAI commited on
Commit
307e206
·
verified ·
1 Parent(s): 269bfb8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +274 -15
app.py CHANGED
@@ -1,7 +1,7 @@
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
5
  import httpx
6
  import json
7
  import logging
@@ -10,6 +10,7 @@ import asyncio
10
  import hashlib
11
  from datetime import datetime, timezone
12
  import re
 
13
 
14
  app = FastAPI()
15
  logging.basicConfig(level=logging.INFO)
@@ -31,13 +32,24 @@ if not os.path.isdir(PERSISTENT_ROOT):
31
  CHAT_DIR = os.path.join(".", "data", "chat")
32
  os.makedirs(CHAT_DIR, exist_ok=True)
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  def _chat_file_for(video_id: str) -> str:
35
  # Stable filename to avoid path traversal
36
  h = hashlib.sha256(video_id.encode("utf-8")).hexdigest()[:32]
37
  return os.path.join(CHAT_DIR, f"{h}.jsonl")
38
 
39
- # Async file locks
40
- app.state.chat_locks = {}
41
 
42
  def _lock_for(path: str) -> asyncio.Lock:
43
  lock = app.state.chat_locks.get(path)
@@ -46,44 +58,126 @@ def _lock_for(path: str) -> asyncio.Lock:
46
  app.state.chat_locks[path] = lock
47
  return lock
48
 
 
49
  def _now_iso() -> str:
50
  return datetime.now(timezone.utc).isoformat()
51
 
 
52
  def _valid_author(s: Optional[str]) -> str:
53
  s = (s or "anon").strip()
54
  s = re.sub(r"\s+", " ", s)
55
  return s[:32] or "anon"
56
 
 
57
  def _valid_text(s: str) -> str:
58
  s = (s or "").rstrip("\n")
59
  return s[:2000]
60
 
 
61
  async def _append_jsonl(path: str, record: dict) -> None:
62
  line = json.dumps(record, ensure_ascii=False) + "\n"
 
63
  def _write():
64
  with open(path, "a", encoding="utf-8") as f:
65
  f.write(line)
 
66
  await asyncio.to_thread(_write)
67
 
 
68
  async def _read_jsonl(path: str) -> List[dict]:
69
  if not os.path.exists(path):
70
  return []
 
71
  def _read():
72
  with open(path, "r", encoding="utf-8") as f:
73
  return [json.loads(x) for x in f if x.strip()]
 
74
  return await asyncio.to_thread(_read)
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  # ---------------------------
77
- # 💬 Chat API
78
  # ---------------------------
79
  class NewMessage(BaseModel):
80
- author: Optional[str] = Field(default="anon", max_length=64)
81
  text: str = Field(..., min_length=1, max_length=5000)
82
 
 
83
  @app.get("/chat/{video_id}")
84
  async def get_messages(video_id: str, limit: int = 50, since: Optional[str] = None):
85
  """
86
- Fetch messages for a video.
87
  - limit: max messages (default 50)
88
  - since: ISO8601 timestamp; return only messages newer than this
89
  """
@@ -107,16 +201,167 @@ async def get_messages(video_id: str, limit: int = 50, since: Optional[str] = No
107
 
108
  return {"video_id": video_id, "count": len(items), "messages": items}
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  @app.post("/chat/{video_id}")
111
  async def post_message(video_id: str, msg: NewMessage, request: Request):
112
  """
113
- Append a message to a video's chat.
114
- Body: { "author": "Ross", "text": "hello" }
115
  """
116
  path = _chat_file_for(video_id)
117
  lock = _lock_for(path)
118
 
119
- author = _valid_author(msg.author)
 
 
 
 
 
 
120
  text = _valid_text(msg.text)
121
  if not text:
122
  return {"ok": False, "error": "Empty message"}
@@ -128,11 +373,14 @@ async def post_message(video_id: str, msg: NewMessage, request: Request):
128
  record = {
129
  "id": mid,
130
  "video_id": video_id,
131
- "author": author, # ✅ FIXED: uses frontend-provided name
 
 
 
132
  "text": text,
133
  "created_at": created,
134
  "ip": ip,
135
- "ua": request.headers.get("user-agent", "")[:200]
136
  }
137
 
138
  async with lock:
@@ -140,22 +388,26 @@ async def post_message(video_id: str, msg: NewMessage, request: Request):
140
 
141
  return {"ok": True, "message": record}
142
 
 
143
  # ---------------------------
144
  # (Original iCloud album endpoints)
145
  # ---------------------------
146
  BASE_62_MAP = {c: i for i, c in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")}
147
 
 
148
  async def get_client() -> httpx.AsyncClient:
149
  if not hasattr(app.state, "client"):
150
  app.state.client = httpx.AsyncClient(timeout=15.0)
151
  return app.state.client
152
 
 
153
  def base62_to_int(token: str) -> int:
154
  result = 0
155
  for ch in token:
156
  result = result * 62 + BASE_62_MAP[ch]
157
  return result
158
 
 
159
  async def get_base_url(token: str) -> str:
160
  first = token[0]
161
  if first == "A":
@@ -164,12 +416,14 @@ async def get_base_url(token: str) -> str:
164
  n = base62_to_int(token[1:3])
165
  return f"https://p{n:02d}-sharedstreams.icloud.com/{token}/sharedstreams/"
166
 
 
167
  ICLOUD_HEADERS = {
168
  "Origin": "https://www.icloud.com",
169
- "Content-Type": "text/plain"
170
  }
171
  ICLOUD_PAYLOAD = '{"streamCtag":null}'
172
 
 
173
  async def get_redirected_base_url(base_url: str, token: str) -> str:
174
  client = await get_client()
175
  resp = await client.post(
@@ -191,21 +445,25 @@ async def get_redirected_base_url(base_url: str, token: str) -> str:
191
  else:
192
  resp.raise_for_status()
193
 
 
194
  async def post_json(path: str, base_url: str, payload: str) -> dict:
195
  client = await get_client()
196
  resp = await client.post(f"{base_url}{path}", headers=ICLOUD_HEADERS, data=payload)
197
  resp.raise_for_status()
198
  return resp.json()
199
 
 
200
  async def get_metadata(base_url: str) -> list:
201
  data = await post_json("webstream", base_url, ICLOUD_PAYLOAD)
202
  return data.get("photos", [])
203
 
 
204
  async def get_asset_urls(base_url: str, guids: list) -> dict:
205
  payload = json.dumps({"photoGuids": guids})
206
  data = await post_json("webasseturls", base_url, payload)
207
  return data.get("items", {})
208
 
 
209
  @app.get("/album/{token}")
210
  async def get_album(token: str):
211
  try:
@@ -225,7 +483,7 @@ async def get_album(token: str):
225
  best = max(
226
  (d for k, d in derivatives.items() if k.lower() != "posterframe"),
227
  key=lambda d: int(d.get("fileSize") or 0),
228
- default=None
229
  )
230
  if not best:
231
  continue
@@ -246,7 +504,7 @@ async def get_album(token: str):
246
  videos.append({
247
  "caption": photo.get("caption", ""),
248
  "url": video_url,
249
- "poster": poster or ""
250
  })
251
 
252
  return {"videos": videos}
@@ -255,6 +513,7 @@ async def get_album(token: str):
255
  logging.exception("Error in get_album")
256
  return {"error": str(e)}
257
 
 
258
  @app.get("/album/{token}/raw")
259
  async def get_album_raw(token: str):
260
  try:
@@ -266,4 +525,4 @@ async def get_album_raw(token: str):
266
  return {"metadata": metadata, "asset_urls": asset_map}
267
  except Exception as e:
268
  logging.exception("Error in get_album_raw")
269
- return {"error": str(e)}
 
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
 
10
  import hashlib
11
  from datetime import datetime, timezone
12
  import re
13
+ import fastapi
14
 
15
  app = FastAPI()
16
  logging.basicConfig(level=logging.INFO)
 
32
  CHAT_DIR = os.path.join(".", "data", "chat")
33
  os.makedirs(CHAT_DIR, exist_ok=True)
34
 
35
+ USERS_DIR = os.path.join(PERSISTENT_ROOT, "users")
36
+ os.makedirs(USERS_DIR, exist_ok=True)
37
+ USERS_FILE = os.path.join(USERS_DIR, "users.jsonl")
38
+ SESSIONS_FILE = os.path.join(USERS_DIR, "sessions.json")
39
+ ADMIN_KEY = os.environ.get("ADMIN_KEY", "") # set in HF Space secrets
40
+
41
+ # Async file locks
42
+ app.state.chat_locks = {}
43
+ app.state.users_lock = asyncio.Lock()
44
+ app.state.sessions_lock = asyncio.Lock()
45
+ app.state.sessions: Dict[str, str] = {}
46
+
47
+
48
  def _chat_file_for(video_id: str) -> str:
49
  # Stable filename to avoid path traversal
50
  h = hashlib.sha256(video_id.encode("utf-8")).hexdigest()[:32]
51
  return os.path.join(CHAT_DIR, f"{h}.jsonl")
52
 
 
 
53
 
54
  def _lock_for(path: str) -> asyncio.Lock:
55
  lock = app.state.chat_locks.get(path)
 
58
  app.state.chat_locks[path] = lock
59
  return lock
60
 
61
+
62
  def _now_iso() -> str:
63
  return datetime.now(timezone.utc).isoformat()
64
 
65
+
66
  def _valid_author(s: Optional[str]) -> str:
67
  s = (s or "anon").strip()
68
  s = re.sub(r"\s+", " ", s)
69
  return s[:32] or "anon"
70
 
71
+
72
  def _valid_text(s: str) -> str:
73
  s = (s or "").rstrip("\n")
74
  return s[:2000]
75
 
76
+
77
  async def _append_jsonl(path: str, record: dict) -> None:
78
  line = json.dumps(record, ensure_ascii=False) + "\n"
79
+
80
  def _write():
81
  with open(path, "a", encoding="utf-8") as f:
82
  f.write(line)
83
+
84
  await asyncio.to_thread(_write)
85
 
86
+
87
  async def _read_jsonl(path: str) -> List[dict]:
88
  if not os.path.exists(path):
89
  return []
90
+
91
  def _read():
92
  with open(path, "r", encoding="utf-8") as f:
93
  return [json.loads(x) for x in f if x.strip()]
94
+
95
  return await asyncio.to_thread(_read)
96
 
97
+
98
+ # ---------------------------
99
+ # 👤 Users & Auth helpers
100
+ # ---------------------------
101
+
102
+ def _safe_handle(s: str) -> str:
103
+ s = (s or "").strip()
104
+ s = re.sub(r"[^a-zA-Z0-9_.~-]", "_", s)
105
+ return s[:24]
106
+
107
+
108
+ def _hash_pin(pin: str) -> str:
109
+ return hashlib.sha256(("tlks|" + pin).encode("utf-8")).hexdigest()
110
+
111
+
112
+ def _rand_token() -> str:
113
+ return hashlib.sha256(os.urandom(32)).hexdigest()
114
+
115
+
116
+ async def _append_user(record: dict) -> None:
117
+ line = json.dumps(record, ensure_ascii=False) + "\n"
118
+
119
+ def _write():
120
+ with open(USERS_FILE, "a", encoding="utf-8") as f:
121
+ f.write(line)
122
+
123
+ async with app.state.users_lock:
124
+ await asyncio.to_thread(_write)
125
+
126
+
127
+ async def _read_users() -> List[dict]:
128
+ if not os.path.exists(USERS_FILE):
129
+ return []
130
+
131
+ def _read():
132
+ with open(USERS_FILE, "r", encoding="utf-8") as f:
133
+ return [json.loads(x) for x in f if x.strip()]
134
+
135
+ async with app.state.users_lock:
136
+ return await asyncio.to_thread(_read)
137
+
138
+
139
+ async def _load_sessions():
140
+ if os.path.exists(SESSIONS_FILE):
141
+ def _read():
142
+ with open(SESSIONS_FILE, "r", encoding="utf-8") as f:
143
+ return json.load(f)
144
+ app.state.sessions = await asyncio.to_thread(_read)
145
+ else:
146
+ app.state.sessions = {}
147
+
148
+
149
+ async def _save_sessions():
150
+ def _write(data):
151
+ with open(SESSIONS_FILE, "w", encoding="utf-8") as f:
152
+ json.dump(data, f)
153
+
154
+ async with app.state.sessions_lock:
155
+ await asyncio.to_thread(_write, app.state.sessions)
156
+
157
+
158
+ @app.on_event("startup")
159
+ async def _startup_load_sessions():
160
+ await _load_sessions()
161
+
162
+
163
+ def _require_admin(request: Request):
164
+ provided = request.headers.get("x-admin-key", "")
165
+ if not ADMIN_KEY or provided != ADMIN_KEY:
166
+ raise fastapi.HTTPException(status_code=401, detail="Admin key required")
167
+
168
+
169
  # ---------------------------
170
+ # 💬 Chat API (public read, authed write)
171
  # ---------------------------
172
  class NewMessage(BaseModel):
173
+ author: Optional[str] = Field(default="anon", max_length=64) # ignored on server write
174
  text: str = Field(..., min_length=1, max_length=5000)
175
 
176
+
177
  @app.get("/chat/{video_id}")
178
  async def get_messages(video_id: str, limit: int = 50, since: Optional[str] = None):
179
  """
180
+ Fetch messages for a room/video.
181
  - limit: max messages (default 50)
182
  - since: ISO8601 timestamp; return only messages newer than this
183
  """
 
201
 
202
  return {"video_id": video_id, "count": len(items), "messages": items}
203
 
204
+
205
+ # ---------------------------
206
+ # 👤 Users & Auth API
207
+ # ---------------------------
208
+ class NewUser(BaseModel):
209
+ email: str
210
+ first_name: str
211
+ last_name: str
212
+ handle: str = Field(..., min_length=2, max_length=24)
213
+ klass: str = Field(..., max_length=64)
214
+ profile_image: Optional[str] = ""
215
+ description: Optional[str] = ""
216
+ pin: str = Field(..., min_length=3, max_length=32)
217
+
218
+
219
+ class UpdateUser(BaseModel):
220
+ first_name: Optional[str] = None
221
+ last_name: Optional[str] = None
222
+ klass: Optional[str] = None
223
+ profile_image: Optional[str] = None
224
+ description: Optional[str] = None
225
+ pin: Optional[str] = None
226
+ disabled: Optional[bool] = None
227
+
228
+
229
+ class LoginReq(BaseModel):
230
+ handle: str
231
+ pin: str
232
+
233
+
234
+ @app.post("/admin/users")
235
+ async def admin_create_user(user: NewUser, request: Request):
236
+ _require_admin(request)
237
+ users = await _read_users()
238
+
239
+ handle = _safe_handle(user.handle.lstrip("@"))
240
+ if any(u.get("handle") == handle for u in users):
241
+ return {"ok": False, "error": "Handle already exists"}
242
+
243
+ rec = {
244
+ "email": user.email.strip(),
245
+ "first_name": user.first_name.strip(),
246
+ "last_name": user.last_name.strip(),
247
+ "handle": handle,
248
+ "klass": user.klass.strip(),
249
+ "profile_image": (user.profile_image or "").strip(),
250
+ "description": (user.description or "").strip(),
251
+ "pin_hash": _hash_pin(user.pin),
252
+ "disabled": False,
253
+ "created_at": _now_iso(),
254
+ }
255
+ await _append_user(rec)
256
+ pub = {k: v for k, v in rec.items() if k != "pin_hash"}
257
+ return {"ok": True, "user": pub}
258
+
259
+
260
+ @app.get("/admin/users")
261
+ async def admin_list_users(request: Request, q: Optional[str] = None, limit: int = 200):
262
+ _require_admin(request)
263
+ users = await _read_users()
264
+ if q:
265
+ ql = q.lower()
266
+ users = [u for u in users if ql in u.get("email", "").lower() or ql in u.get("handle", "").lower()]
267
+ users = users[: max(1, min(limit, 1000))]
268
+ for u in users:
269
+ u.pop("pin_hash", None)
270
+ return {"count": len(users), "users": users}
271
+
272
+
273
+ @app.put("/admin/users/{handle}")
274
+ async def admin_update_user(handle: str, patch: UpdateUser, request: Request):
275
+ _require_admin(request)
276
+ handle = _safe_handle(handle)
277
+ users = await _read_users()
278
+ changed = False
279
+ for u in users:
280
+ if u.get("handle") == handle:
281
+ if patch.first_name is not None:
282
+ u["first_name"] = patch.first_name.strip()
283
+ if patch.last_name is not None:
284
+ u["last_name"] = patch.last_name.strip()
285
+ if patch.klass is not None:
286
+ u["klass"] = patch.klass.strip()
287
+ if patch.profile_image is not None:
288
+ u["profile_image"] = patch.profile_image.strip()
289
+ if patch.description is not None:
290
+ u["description"] = patch.description.strip()
291
+ if patch.disabled is not None:
292
+ u["disabled"] = bool(patch.disabled)
293
+ if patch.pin is not None:
294
+ u["pin_hash"] = _hash_pin(patch.pin)
295
+ changed = True
296
+ break
297
+ if not changed:
298
+ raise fastapi.HTTPException(status_code=404, detail="User not found")
299
+
300
+ # rewrite file
301
+ async with app.state.users_lock:
302
+ def _write_all():
303
+ with open(USERS_FILE, "w", encoding="utf-8") as f:
304
+ for rec in users:
305
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
306
+ await asyncio.to_thread(_write_all)
307
+ return {"ok": True}
308
+
309
+
310
+ @app.post("/auth/login")
311
+ async def login(req: LoginReq):
312
+ users = await _read_users()
313
+ handle = _safe_handle(req.handle.lstrip("@"))
314
+ u = next((x for x in users if x.get("handle") == handle), None)
315
+ if not u or u.get("disabled"):
316
+ return {"ok": False, "error": "Invalid user"}
317
+ if u.get("pin_hash") != _hash_pin(req.pin):
318
+ return {"ok": False, "error": "Invalid PIN"}
319
+
320
+ token = _rand_token()
321
+ app.state.sessions[token] = handle
322
+ await _save_sessions()
323
+ return {"ok": True, "token": token}
324
+
325
+
326
+ async def _require_user(request: Request) -> dict:
327
+ auth = request.headers.get("authorization", "")
328
+ if not auth.lower().startswith("bearer "):
329
+ raise fastapi.HTTPException(status_code=401, detail="Missing bearer token")
330
+ token = auth.split(" ", 1)[1].strip()
331
+ handle = app.state.sessions.get(token)
332
+ if not handle:
333
+ raise fastapi.HTTPException(status_code=401, detail="Invalid session")
334
+
335
+ users = await _read_users()
336
+ user = next((u for u in users if u.get("handle") == handle), None)
337
+ if not user or user.get("disabled"):
338
+ raise fastapi.HTTPException(status_code=401, detail="User disabled")
339
+ return user
340
+
341
+
342
+ @app.get("/me")
343
+ async def me(request: Request):
344
+ user = await _require_user(request)
345
+ pub = {k: v for k, v in user.items() if k not in ("pin_hash",)}
346
+ return {"ok": True, "user": pub}
347
+
348
+
349
  @app.post("/chat/{video_id}")
350
  async def post_message(video_id: str, msg: NewMessage, request: Request):
351
  """
352
+ Append a message to a room's chat.
353
+ Body: { "text": "hello" }
354
  """
355
  path = _chat_file_for(video_id)
356
  lock = _lock_for(path)
357
 
358
+ # Require a valid session and take author from the user profile
359
+ user = await _require_user(request)
360
+ author = _valid_author(f"{user.get('first_name','')} {user.get('last_name','')}".strip() or user["handle"])
361
+ handle = user["handle"]
362
+ klass = user.get("klass", "")
363
+ profile_image = user.get("profile_image", "")
364
+
365
  text = _valid_text(msg.text)
366
  if not text:
367
  return {"ok": False, "error": "Empty message"}
 
373
  record = {
374
  "id": mid,
375
  "video_id": video_id,
376
+ "author": author,
377
+ "handle": handle,
378
+ "klass": klass,
379
+ "profile_image": profile_image,
380
  "text": text,
381
  "created_at": created,
382
  "ip": ip,
383
+ "ua": request.headers.get("user-agent", "")[:200],
384
  }
385
 
386
  async with lock:
 
388
 
389
  return {"ok": True, "message": record}
390
 
391
+
392
  # ---------------------------
393
  # (Original iCloud album endpoints)
394
  # ---------------------------
395
  BASE_62_MAP = {c: i for i, c in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")}
396
 
397
+
398
  async def get_client() -> httpx.AsyncClient:
399
  if not hasattr(app.state, "client"):
400
  app.state.client = httpx.AsyncClient(timeout=15.0)
401
  return app.state.client
402
 
403
+
404
  def base62_to_int(token: str) -> int:
405
  result = 0
406
  for ch in token:
407
  result = result * 62 + BASE_62_MAP[ch]
408
  return result
409
 
410
+
411
  async def get_base_url(token: str) -> str:
412
  first = token[0]
413
  if first == "A":
 
416
  n = base62_to_int(token[1:3])
417
  return f"https://p{n:02d}-sharedstreams.icloud.com/{token}/sharedstreams/"
418
 
419
+
420
  ICLOUD_HEADERS = {
421
  "Origin": "https://www.icloud.com",
422
+ "Content-Type": "text/plain",
423
  }
424
  ICLOUD_PAYLOAD = '{"streamCtag":null}'
425
 
426
+
427
  async def get_redirected_base_url(base_url: str, token: str) -> str:
428
  client = await get_client()
429
  resp = await client.post(
 
445
  else:
446
  resp.raise_for_status()
447
 
448
+
449
  async def post_json(path: str, base_url: str, payload: str) -> dict:
450
  client = await get_client()
451
  resp = await client.post(f"{base_url}{path}", headers=ICLOUD_HEADERS, data=payload)
452
  resp.raise_for_status()
453
  return resp.json()
454
 
455
+
456
  async def get_metadata(base_url: str) -> list:
457
  data = await post_json("webstream", base_url, ICLOUD_PAYLOAD)
458
  return data.get("photos", [])
459
 
460
+
461
  async def get_asset_urls(base_url: str, guids: list) -> dict:
462
  payload = json.dumps({"photoGuids": guids})
463
  data = await post_json("webasseturls", base_url, payload)
464
  return data.get("items", {})
465
 
466
+
467
  @app.get("/album/{token}")
468
  async def get_album(token: str):
469
  try:
 
483
  best = max(
484
  (d for k, d in derivatives.items() if k.lower() != "posterframe"),
485
  key=lambda d: int(d.get("fileSize") or 0),
486
+ default=None,
487
  )
488
  if not best:
489
  continue
 
504
  videos.append({
505
  "caption": photo.get("caption", ""),
506
  "url": video_url,
507
+ "poster": poster or "",
508
  })
509
 
510
  return {"videos": videos}
 
513
  logging.exception("Error in get_album")
514
  return {"error": str(e)}
515
 
516
+
517
  @app.get("/album/{token}/raw")
518
  async def get_album_raw(token: str):
519
  try:
 
525
  return {"metadata": metadata, "asset_urls": asset_map}
526
  except Exception as e:
527
  logging.exception("Error in get_album_raw")
528
+ return {"error": str(e)}