SalexAI commited on
Commit
2b76608
·
verified ·
1 Parent(s): 921564c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +534 -138
app.py CHANGED
@@ -8,21 +8,9 @@ import logging
8
  import os
9
  import asyncio
10
  import secrets
 
11
  from datetime import datetime, timezone
12
 
13
- # ==================================================
14
- # CONFIG
15
- # ==================================================
16
- ADMIN_PIN = os.environ.get("ADMIN_PIN", "4286") # CHANGE THIS
17
- ADMIN_COOKIE = "admin_session"
18
- ADMIN_SESSIONS = set()
19
-
20
- DATA_ROOT = os.environ.get("PERSISTENT_DIR", "/data")
21
- VIDEO_DIR = os.path.join(DATA_ROOT, "videos")
22
- INDEX_FILE = os.path.join(DATA_ROOT, "index.json")
23
-
24
- os.makedirs(VIDEO_DIR, exist_ok=True)
25
-
26
  # ==================================================
27
  # APP SETUP
28
  # ==================================================
@@ -37,9 +25,29 @@ app.add_middleware(
37
  )
38
 
39
  # ==================================================
40
- # ALBUM METADATA
41
  # ==================================================
42
- ALBUM_PUBLISHERS = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  "B2c5n8hH4uWRoAW": "Alex Rose",
44
  "B2c5yeZFhHXzdFg": "Central Space Program",
45
  "B2cGI9HKKtaAF3T": "Sam Holden",
@@ -48,7 +56,7 @@ ALBUM_PUBLISHERS = {
48
  "B2c5ON9t3uz8kT7": "Cole Vandepoll",
49
  }
50
 
51
- ALBUM_CATEGORIES = {
52
  "B2c5n8hH4uWRoAW": "Fun",
53
  "B2c5yeZFhHXzdFg": "Rockets",
54
  "B2cGI9HKKtaAF3T": "Sam Content Library",
@@ -57,16 +65,100 @@ ALBUM_CATEGORIES = {
57
  "B2c5ON9t3uz8kT7": "Cole Content Creator",
58
  }
59
 
 
 
 
 
 
 
 
 
 
 
60
  # ==================================================
61
- # CLIENT
62
  # ==================================================
63
- async def get_client():
64
  if not hasattr(app.state, "client"):
65
- app.state.client = httpx.AsyncClient(timeout=30)
66
  return app.state.client
67
 
68
  # ==================================================
69
- # BASE62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  # ==================================================
71
  BASE_62_MAP = {c: i for i, c in enumerate(
72
  "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
@@ -79,7 +171,7 @@ def base62_to_int(token: str) -> int:
79
  return n
80
 
81
  # ==================================================
82
- # ICLOUD HELPERS
83
  # ==================================================
84
  ICLOUD_HEADERS = {
85
  "Origin": "https://www.icloud.com",
@@ -88,7 +180,11 @@ ICLOUD_HEADERS = {
88
  ICLOUD_PAYLOAD = '{"streamCtag":null}'
89
 
90
  async def get_base_url(token: str) -> str:
91
- n = base62_to_int(token[1:3])
 
 
 
 
92
  return f"https://p{n:02d}-sharedstreams.icloud.com/{token}/sharedstreams/"
93
 
94
  async def get_redirected_base_url(base_url: str, token: str) -> str:
@@ -99,42 +195,39 @@ async def get_redirected_base_url(base_url: str, token: str) -> str:
99
  data=ICLOUD_PAYLOAD,
100
  follow_redirects=False,
101
  )
 
102
  if r.status_code == 330:
103
- host = r.json()["X-Apple-MMe-Host"]
 
 
104
  return f"https://{host}/{token}/sharedstreams/"
105
- return base_url
106
 
107
- async def post_json(path: str, base_url: str, payload: str):
 
 
 
 
 
108
  client = await get_client()
109
- r = await client.post(f"{base_url}{path}", headers=ICLOUD_HEADERS, data=payload)
 
 
 
 
110
  r.raise_for_status()
111
  return r.json()
112
 
113
- async def get_metadata(base_url: str):
114
  return (await post_json("webstream", base_url, ICLOUD_PAYLOAD)).get("photos", [])
115
 
116
- async def get_asset_urls(base_url: str, guids: list):
117
- return (await post_json(
118
- "webasseturls", base_url, json.dumps({"photoGuids": guids})
119
- )).get("items", {})
120
-
121
- # ==================================================
122
- # INDEX HELPERS
123
- # ==================================================
124
- def load_index():
125
- if not os.path.exists(INDEX_FILE):
126
- return {"videos": []}
127
- with open(INDEX_FILE, "r", encoding="utf-8") as f:
128
- return json.load(f)
129
-
130
- def save_index(data):
131
- with open(INDEX_FILE, "w", encoding="utf-8") as f:
132
- json.dump(data, f, indent=2)
133
 
134
  # ==================================================
135
  # DOWNLOADER
136
  # ==================================================
137
- async def download_file(url, path):
138
  client = await get_client()
139
  async with client.stream("GET", url) as r:
140
  r.raise_for_status()
@@ -143,29 +236,40 @@ async def download_file(url, path):
143
  f.write(chunk)
144
 
145
  # ==================================================
146
- # POLLING
147
  # ==================================================
148
- async def poll_album(token):
149
- base = await get_redirected_base_url(await get_base_url(token), token)
150
- metadata = await get_metadata(base)
 
 
151
  guids = [p["photoGuid"] for p in metadata]
152
- assets = await get_asset_urls(base, guids)
 
 
 
 
 
153
 
154
- index = load_index()
155
- known = {v["id"] for v in index["videos"]}
156
 
157
  album_dir = os.path.join(VIDEO_DIR, token)
158
  os.makedirs(album_dir, exist_ok=True)
159
 
160
- for p in metadata:
161
- if p.get("mediaAssetType", "").lower() != "video":
 
 
162
  continue
163
- vid = p["photoGuid"]
 
164
  if vid in known:
165
  continue
166
 
 
167
  best = max(
168
- (d for k, d in p["derivatives"].items() if k.lower() != "posterframe"),
169
  key=lambda d: int(d.get("fileSize") or 0),
170
  default=None,
171
  )
@@ -176,141 +280,433 @@ async def poll_album(token):
176
  if not asset:
177
  continue
178
 
 
179
  video_path = os.path.join(album_dir, f"{vid}.mp4")
180
- await download_file(
181
- f"https://{asset['url_location']}{asset['url_path']}", video_path
182
- )
183
 
184
- thumb = ""
185
- pf = p["derivatives"].get("PosterFrame")
186
  if pf:
187
- a = assets.get(pf["checksum"])
188
- if a:
189
- thumb = os.path.join(album_dir, f"{vid}.jpg")
190
- await download_file(
191
- f"https://{a['url_location']}{a['url_path']}", thumb
192
- )
193
-
194
- index["videos"].append({
195
  "id": vid,
196
- "name": p.get("caption") or "Untitled",
197
  "video_url": f"/media/{token}/{vid}.mp4",
198
- "thumbnail": f"/media/{token}/{vid}.jpg" if thumb else "",
199
- "upload_date": p.get("creationDate") or datetime.now(timezone.utc).isoformat(),
200
- "category": ALBUM_CATEGORIES.get(token, "Uncategorized"),
201
- "publisher": ALBUM_PUBLISHERS.get(token, "Unknown"),
202
  "source_album": token,
203
  })
204
 
205
- save_index(index)
 
 
 
 
 
 
 
206
 
 
 
 
207
  @app.on_event("startup")
208
  async def start_polling():
 
 
 
 
 
 
 
 
 
 
209
  async def loop():
210
  while True:
211
- for token in ALBUM_PUBLISHERS:
 
 
212
  try:
213
  await poll_album(token)
214
  except Exception:
215
- logging.exception("Polling failed")
216
  await asyncio.sleep(60)
 
217
  asyncio.create_task(loop())
218
 
219
  # ==================================================
220
  # FEED
221
  # ==================================================
222
  @app.get("/feed/videos")
223
- async def feed():
224
- return load_index()
 
225
 
226
  # ==================================================
227
- # ADMIN AUTH
228
  # ==================================================
229
- def is_admin(req: Request):
230
- return req.cookies.get(ADMIN_COOKIE) in ADMIN_SESSIONS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
  # ==================================================
233
- # ADMIN LOGIN
234
  # ==================================================
235
  @app.get("/admin/login", response_class=HTMLResponse)
236
  async def admin_login_page():
 
237
  return """
238
- <form method="post">
239
- <h2>Admin Login</h2>
240
- <input name="pin" type="password"/>
241
- <button>Enter</button>
242
- </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  """
244
 
245
  @app.post("/admin/login")
246
  async def admin_login(pin: str = Form(...)):
247
  if pin != ADMIN_PIN:
248
  return HTMLResponse("Wrong PIN", status_code=401)
 
249
  session = secrets.token_hex(16)
250
  ADMIN_SESSIONS.add(session)
251
- r = RedirectResponse("/admin", 302)
252
- r.set_cookie(ADMIN_COOKIE, session, httponly=True)
253
- return r
 
 
 
 
 
 
 
 
 
 
254
 
255
  # ==================================================
256
- # ADMIN DASHBOARD
257
  # ==================================================
258
- @app.get("/admin", response_class=HTMLResponse)
259
- async def admin(req: Request):
260
- if not is_admin(req):
261
- return RedirectResponse("/admin/login", 302)
262
-
263
- idx = load_index()
264
- rows = ""
265
- for v in idx["videos"]:
266
- rows += f"""
267
- <tr>
268
- <td>{v['id']}</td>
269
- <td><input data-id="{v['id']}" data-field="name" value="{v['name']}"></td>
270
- <td><input data-id="{v['id']}" data-field="upload_date" value="{v['upload_date']}"></td>
271
- <td><input data-id="{v['id']}" data-field="category" value="{v['category']}"></td>
272
- <td><input data-id="{v['id']}" data-field="publisher" value="{v['publisher']}"></td>
273
- <td><input data-id="{v['id']}" data-field="thumbnail" value="{v['thumbnail']}"></td>
274
- </tr>
275
- """
276
-
277
- return f"""
278
- <table border=1>
279
- <tr><th>ID</th><th>Name</th><th>Date</th><th>Category</th><th>Publisher</th><th>Thumb</th></tr>
280
- {rows}
281
- </table>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  <script>
283
- document.querySelectorAll("input").forEach(i=>{
284
- i.onchange=()=>fetch("/admin/update",{
285
- method:"POST",
286
- headers:{{"Content-Type":"application/json"}},
287
- body:JSON.stringify({{
288
- id:i.dataset.id,
289
- field:i.dataset.field,
290
- value:i.value
291
- }})
292
- })
293
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  </script>
295
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
 
297
  # ==================================================
298
- # ADMIN UPDATE
299
  # ==================================================
 
 
 
 
 
 
300
  @app.post("/admin/update")
301
  async def admin_update(req: Request, payload: dict):
302
  if not is_admin(req):
303
- return JSONResponse({"error":"unauthorized"},403)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
 
305
- idx = load_index()
306
- for v in idx["videos"]:
307
- if v["id"] == payload["id"]:
308
- v[payload["field"]] = payload["value"]
309
- save_index(idx)
310
- return {"ok":True}
311
- return {"error":"not found"}
312
 
313
  # ==================================================
314
- # MEDIA
315
  # ==================================================
316
  app.mount("/media", StaticFiles(directory=VIDEO_DIR), name="media")
 
8
  import os
9
  import asyncio
10
  import secrets
11
+ import html as html_escape_lib
12
  from datetime import datetime, timezone
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  # ==================================================
15
  # APP SETUP
16
  # ==================================================
 
25
  )
26
 
27
  # ==================================================
28
+ # STORAGE
29
  # ==================================================
30
+ DATA_ROOT = os.environ.get("PERSISTENT_DIR", "/data")
31
+ VIDEO_DIR = os.path.join(DATA_ROOT, "videos")
32
+ INDEX_FILE = os.path.join(DATA_ROOT, "index.json")
33
+ ADMIN_CONFIG_FILE = os.path.join(DATA_ROOT, "admin_config.json")
34
+
35
+ os.makedirs(VIDEO_DIR, exist_ok=True)
36
+
37
+ # ==================================================
38
+ # ADMIN AUTH (PIN)
39
+ # ==================================================
40
+ ADMIN_PIN = os.environ.get("ADMIN_PIN", "4286") # CHANGE THIS
41
+ ADMIN_COOKIE = "admin_session"
42
+ ADMIN_SESSIONS = set()
43
+
44
+ def is_admin(req: Request) -> bool:
45
+ return req.cookies.get(ADMIN_COOKIE) in ADMIN_SESSIONS
46
+
47
+ # ==================================================
48
+ # DEFAULT ALBUM MAPS (used only if admin_config.json missing)
49
+ # ==================================================
50
+ DEFAULT_ALBUM_PUBLISHERS = {
51
  "B2c5n8hH4uWRoAW": "Alex Rose",
52
  "B2c5yeZFhHXzdFg": "Central Space Program",
53
  "B2cGI9HKKtaAF3T": "Sam Holden",
 
56
  "B2c5ON9t3uz8kT7": "Cole Vandepoll",
57
  }
58
 
59
+ DEFAULT_ALBUM_CATEGORIES = {
60
  "B2c5n8hH4uWRoAW": "Fun",
61
  "B2c5yeZFhHXzdFg": "Rockets",
62
  "B2cGI9HKKtaAF3T": "Sam Content Library",
 
65
  "B2c5ON9t3uz8kT7": "Cole Content Creator",
66
  }
67
 
68
+ # In-memory config (loaded at startup)
69
+ ALBUM_PUBLISHERS = dict(DEFAULT_ALBUM_PUBLISHERS)
70
+ ALBUM_CATEGORIES = dict(DEFAULT_ALBUM_CATEGORIES)
71
+
72
+ # ==================================================
73
+ # LOCKS (avoid index.json corruption)
74
+ # ==================================================
75
+ INDEX_LOCK = asyncio.Lock()
76
+ CONFIG_LOCK = asyncio.Lock()
77
+
78
  # ==================================================
79
+ # HTTP CLIENT
80
  # ==================================================
81
+ async def get_client() -> httpx.AsyncClient:
82
  if not hasattr(app.state, "client"):
83
+ app.state.client = httpx.AsyncClient(timeout=20.0)
84
  return app.state.client
85
 
86
  # ==================================================
87
+ # JSON FILE HELPERS (atomic)
88
+ # ==================================================
89
+ def _atomic_write_json(path: str, data: dict):
90
+ tmp = path + ".tmp"
91
+ with open(tmp, "w", encoding="utf-8") as f:
92
+ json.dump(data, f, indent=2)
93
+ os.replace(tmp, path)
94
+
95
+ def load_index_sync() -> dict:
96
+ if not os.path.exists(INDEX_FILE):
97
+ return {"videos": []}
98
+ with open(INDEX_FILE, "r", encoding="utf-8") as f:
99
+ return json.load(f)
100
+
101
+ def save_index_sync(data: dict):
102
+ _atomic_write_json(INDEX_FILE, data)
103
+
104
+ def load_admin_config_sync() -> dict:
105
+ if not os.path.exists(ADMIN_CONFIG_FILE):
106
+ return {
107
+ "album_publishers": dict(DEFAULT_ALBUM_PUBLISHERS),
108
+ "album_categories": dict(DEFAULT_ALBUM_CATEGORIES),
109
+ }
110
+ with open(ADMIN_CONFIG_FILE, "r", encoding="utf-8") as f:
111
+ return json.load(f)
112
+
113
+ def save_admin_config_sync(cfg: dict):
114
+ _atomic_write_json(ADMIN_CONFIG_FILE, cfg)
115
+
116
+ async def load_admin_config():
117
+ async with CONFIG_LOCK:
118
+ return load_admin_config_sync()
119
+
120
+ async def write_admin_config(cfg: dict):
121
+ async with CONFIG_LOCK:
122
+ save_admin_config_sync(cfg)
123
+
124
+ async def refresh_album_maps_from_disk():
125
+ global ALBUM_PUBLISHERS, ALBUM_CATEGORIES
126
+ cfg = await load_admin_config()
127
+ ALBUM_PUBLISHERS = dict(cfg.get("album_publishers", {}))
128
+ ALBUM_CATEGORIES = dict(cfg.get("album_categories", {}))
129
+
130
+ # ==================================================
131
+ # BACKFILL CATEGORIES/PUBLISHERS (fix old cached entries)
132
+ # ==================================================
133
+ async def backfill_index_categories():
134
+ try:
135
+ async with INDEX_LOCK:
136
+ idx = load_index_sync()
137
+ changed = False
138
+
139
+ for v in idx.get("videos", []):
140
+ token = v.get("source_album", "")
141
+ correct_category = ALBUM_CATEGORIES.get(token, "Uncategorized")
142
+ correct_publisher = ALBUM_PUBLISHERS.get(token, "Unknown")
143
+
144
+ # Fix publisher if missing
145
+ if v.get("publisher") in (None, "", "Unknown") and correct_publisher != "Unknown":
146
+ v["publisher"] = correct_publisher
147
+ changed = True
148
+
149
+ # Fix category if missing or wrong
150
+ if v.get("category") in (None, "", v.get("publisher")) or v.get("category") != correct_category:
151
+ v["category"] = correct_category
152
+ changed = True
153
+
154
+ if changed:
155
+ save_index_sync(idx)
156
+ logging.info("Backfilled index.json categories/publishers")
157
+ except Exception:
158
+ logging.exception("Backfill failed (non-fatal)")
159
+
160
+ # ==================================================
161
+ # BASE62 HELPERS
162
  # ==================================================
163
  BASE_62_MAP = {c: i for i, c in enumerate(
164
  "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
 
171
  return n
172
 
173
  # ==================================================
174
+ # iCLOUD HELPERS
175
  # ==================================================
176
  ICLOUD_HEADERS = {
177
  "Origin": "https://www.icloud.com",
 
180
  ICLOUD_PAYLOAD = '{"streamCtag":null}'
181
 
182
  async def get_base_url(token: str) -> str:
183
+ # Keep your original logic
184
+ if token and token[0] == "A":
185
+ n = base62_to_int(token[1])
186
+ else:
187
+ n = base62_to_int(token[1:3])
188
  return f"https://p{n:02d}-sharedstreams.icloud.com/{token}/sharedstreams/"
189
 
190
  async def get_redirected_base_url(base_url: str, token: str) -> str:
 
195
  data=ICLOUD_PAYLOAD,
196
  follow_redirects=False,
197
  )
198
+
199
  if r.status_code == 330:
200
+ host = r.json().get("X-Apple-MMe-Host")
201
+ if not host:
202
+ raise RuntimeError("Missing redirect host")
203
  return f"https://{host}/{token}/sharedstreams/"
 
204
 
205
+ if r.status_code == 200:
206
+ return base_url
207
+
208
+ r.raise_for_status()
209
+
210
+ async def post_json(path: str, base_url: str, payload: str) -> dict:
211
  client = await get_client()
212
+ r = await client.post(
213
+ f"{base_url}{path}",
214
+ headers=ICLOUD_HEADERS,
215
+ data=payload,
216
+ )
217
  r.raise_for_status()
218
  return r.json()
219
 
220
+ async def get_metadata(base_url: str) -> list:
221
  return (await post_json("webstream", base_url, ICLOUD_PAYLOAD)).get("photos", [])
222
 
223
+ async def get_asset_urls(base_url: str, guids: list) -> dict:
224
+ payload = json.dumps({"photoGuids": guids})
225
+ return (await post_json("webasseturls", base_url, payload)).get("items", {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
 
227
  # ==================================================
228
  # DOWNLOADER
229
  # ==================================================
230
+ async def download_file(url: str, path: str):
231
  client = await get_client()
232
  async with client.stream("GET", url) as r:
233
  r.raise_for_status()
 
236
  f.write(chunk)
237
 
238
  # ==================================================
239
+ # POLL + INGEST ALBUM
240
  # ==================================================
241
+ async def poll_album(token: str):
242
+ base_url = await get_base_url(token)
243
+ base_url = await get_redirected_base_url(base_url, token)
244
+
245
+ metadata = await get_metadata(base_url)
246
  guids = [p["photoGuid"] for p in metadata]
247
+ assets = await get_asset_urls(base_url, guids)
248
+
249
+ # Snapshot known IDs under lock (so we don't race writes)
250
+ async with INDEX_LOCK:
251
+ index = load_index_sync()
252
+ known = {v["id"] for v in index.get("videos", [])}
253
 
254
+ publisher = ALBUM_PUBLISHERS.get(token, "Unknown")
255
+ category = ALBUM_CATEGORIES.get(token, "Uncategorized")
256
 
257
  album_dir = os.path.join(VIDEO_DIR, token)
258
  os.makedirs(album_dir, exist_ok=True)
259
 
260
+ new_entries = []
261
+
262
+ for photo in metadata:
263
+ if photo.get("mediaAssetType", "").lower() != "video":
264
  continue
265
+
266
+ vid = photo["photoGuid"]
267
  if vid in known:
268
  continue
269
 
270
+ derivatives = photo.get("derivatives", {})
271
  best = max(
272
+ (d for k, d in derivatives.items() if k.lower() != "posterframe"),
273
  key=lambda d: int(d.get("fileSize") or 0),
274
  default=None,
275
  )
 
280
  if not asset:
281
  continue
282
 
283
+ video_url = f"https://{asset['url_location']}{asset['url_path']}"
284
  video_path = os.path.join(album_dir, f"{vid}.mp4")
285
+ await download_file(video_url, video_path)
 
 
286
 
287
+ thumb_path = ""
288
+ pf = derivatives.get("PosterFrame")
289
  if pf:
290
+ pf_asset = assets.get(pf.get("checksum"))
291
+ if pf_asset:
292
+ poster_url = f"https://{pf_asset['url_location']}{pf_asset['url_path']}"
293
+ thumb_path = os.path.join(album_dir, f"{vid}.jpg")
294
+ await download_file(poster_url, thumb_path)
295
+
296
+ new_entries.append({
 
297
  "id": vid,
298
+ "name": photo.get("caption") or "Untitled",
299
  "video_url": f"/media/{token}/{vid}.mp4",
300
+ "thumbnail": f"/media/{token}/{vid}.jpg" if thumb_path else "",
301
+ "upload_date": photo.get("creationDate") or datetime.now(timezone.utc).isoformat(),
302
+ "category": category,
303
+ "publisher": publisher,
304
  "source_album": token,
305
  })
306
 
307
+ if new_entries:
308
+ async with INDEX_LOCK:
309
+ idx = load_index_sync()
310
+ existing = {v["id"] for v in idx.get("videos", [])}
311
+ for e in new_entries:
312
+ if e["id"] not in existing:
313
+ idx["videos"].append(e)
314
+ save_index_sync(idx)
315
 
316
+ # ==================================================
317
+ # STARTUP: LOAD CONFIG + BACKFILL + START POLLER
318
+ # ==================================================
319
  @app.on_event("startup")
320
  async def start_polling():
321
+ # Ensure config exists
322
+ if not os.path.exists(ADMIN_CONFIG_FILE):
323
+ save_admin_config_sync({
324
+ "album_publishers": dict(DEFAULT_ALBUM_PUBLISHERS),
325
+ "album_categories": dict(DEFAULT_ALBUM_CATEGORIES),
326
+ })
327
+
328
+ await refresh_album_maps_from_disk()
329
+ await backfill_index_categories()
330
+
331
  async def loop():
332
  while True:
333
+ # Iterate dynamic albums from config
334
+ tokens = list(ALBUM_PUBLISHERS.keys())
335
+ for token in tokens:
336
  try:
337
  await poll_album(token)
338
  except Exception:
339
+ logging.exception(f"Polling failed for {token}")
340
  await asyncio.sleep(60)
341
+
342
  asyncio.create_task(loop())
343
 
344
  # ==================================================
345
  # FEED
346
  # ==================================================
347
  @app.get("/feed/videos")
348
+ async def get_video_feed():
349
+ async with INDEX_LOCK:
350
+ return load_index_sync()
351
 
352
  # ==================================================
353
+ # LEGACY ENDPOINTS (unchanged)
354
  # ==================================================
355
+ @app.get("/album/{token}")
356
+ async def legacy_album(token: str):
357
+ base_url = await get_base_url(token)
358
+ base_url = await get_redirected_base_url(base_url, token)
359
+ metadata = await get_metadata(base_url)
360
+ guids = [p["photoGuid"] for p in metadata]
361
+ assets = await get_asset_urls(base_url, guids)
362
+
363
+ videos = []
364
+ for photo in metadata:
365
+ if photo.get("mediaAssetType", "").lower() != "video":
366
+ continue
367
+
368
+ derivatives = photo.get("derivatives", {})
369
+ best = max(
370
+ (d for k, d in derivatives.items() if k.lower() != "posterframe"),
371
+ key=lambda d: int(d.get("fileSize") or 0),
372
+ default=None,
373
+ )
374
+ if not best:
375
+ continue
376
+
377
+ asset = assets.get(best["checksum"])
378
+ if not asset:
379
+ continue
380
+
381
+ videos.append({
382
+ "caption": photo.get("caption", ""),
383
+ "url": f"https://{asset['url_location']}{asset['url_path']}",
384
+ })
385
+
386
+ return {"videos": videos}
387
+
388
+ @app.get("/album/{token}/raw")
389
+ async def legacy_album_raw(token: str):
390
+ base_url = await get_base_url(token)
391
+ base_url = await get_redirected_base_url(base_url, token)
392
+ metadata = await get_metadata(base_url)
393
+ guids = [p["photoGuid"] for p in metadata]
394
+ assets = await get_asset_urls(base_url, guids)
395
+ return {"metadata": metadata, "asset_urls": assets}
396
 
397
  # ==================================================
398
+ # ADMIN: LOGIN
399
  # ==================================================
400
  @app.get("/admin/login", response_class=HTMLResponse)
401
  async def admin_login_page():
402
+ # No f-string here -> no brace issues
403
  return """
404
+ <html>
405
+ <head>
406
+ <title>Admin Login</title>
407
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
408
+ <style>
409
+ body { font-family: system-ui, sans-serif; background:#111; color:#fff; display:flex; min-height:100vh; align-items:center; justify-content:center; }
410
+ .card { width: 360px; padding: 18px; border: 1px solid #333; border-radius: 12px; background:#161616; }
411
+ input, button { width:100%; padding:12px; font-size:16px; border-radius:10px; border:1px solid #333; background:#0f0f0f; color:#fff; }
412
+ button { background:#2a2a2a; cursor:pointer; margin-top:10px; }
413
+ button:hover { background:#3a3a3a; }
414
+ </style>
415
+ </head>
416
+ <body>
417
+ <form class="card" method="post">
418
+ <h2 style="margin:0 0 10px 0;">Admin Panel</h2>
419
+ <input type="password" name="pin" placeholder="PIN" autofocus />
420
+ <button type="submit">Enter</button>
421
+ </form>
422
+ </body>
423
+ </html>
424
  """
425
 
426
  @app.post("/admin/login")
427
  async def admin_login(pin: str = Form(...)):
428
  if pin != ADMIN_PIN:
429
  return HTMLResponse("Wrong PIN", status_code=401)
430
+
431
  session = secrets.token_hex(16)
432
  ADMIN_SESSIONS.add(session)
433
+
434
+ resp = RedirectResponse("/admin", status_code=302)
435
+ resp.set_cookie(ADMIN_COOKIE, session, httponly=True)
436
+ return resp
437
+
438
+ @app.get("/admin/logout")
439
+ async def admin_logout(req: Request):
440
+ session = req.cookies.get(ADMIN_COOKIE)
441
+ if session in ADMIN_SESSIONS:
442
+ ADMIN_SESSIONS.remove(session)
443
+ resp = RedirectResponse("/admin/login", status_code=302)
444
+ resp.delete_cookie(ADMIN_COOKIE)
445
+ return resp
446
 
447
  # ==================================================
448
+ # ADMIN: DASHBOARD (VIDEOS + ALBUMS)
449
  # ==================================================
450
+ def esc(v) -> str:
451
+ return html_escape_lib.escape("" if v is None else str(v), quote=True)
452
+
453
+ ADMIN_TEMPLATE = """
454
+ <html>
455
+ <head>
456
+ <title>Admin</title>
457
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
458
+ <style>
459
+ body { font-family: system-ui, sans-serif; background:#111; color:#fff; margin:0; padding:16px; }
460
+ a { color:#9ad; }
461
+ h1 { margin: 0 0 10px 0; }
462
+ .row { display:flex; gap:10px; align-items:center; margin-bottom:12px; flex-wrap:wrap; }
463
+ .pill { padding:6px 10px; border:1px solid #333; border-radius:999px; background:#161616; }
464
+ .card { border:1px solid #333; border-radius:12px; background:#161616; padding:12px; margin: 14px 0; }
465
+ table { width:100%; border-collapse: collapse; }
466
+ th, td { border-bottom:1px solid #2a2a2a; padding:8px; vertical-align: top; }
467
+ th { text-align:left; position:sticky; top:0; background:#161616; }
468
+ input { width:100%; padding:8px; border-radius:10px; border:1px solid #333; background:#0f0f0f; color:#fff; }
469
+ button { padding:10px 12px; border-radius:10px; border:1px solid #333; background:#2a2a2a; color:#fff; cursor:pointer; }
470
+ button:hover { background:#3a3a3a; }
471
+ .small { font-size:12px; opacity:0.85; }
472
+ .grid2 { display:grid; grid-template-columns: 1fr 1fr; gap:10px; }
473
+ @media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; } }
474
+ </style>
475
+ </head>
476
+ <body>
477
+ <div class="row">
478
+ <h1 style="margin-right:auto;">Admin</h1>
479
+ <span class="pill">PIN session active</span>
480
+ <a href="/admin/logout" class="pill">Logout</a>
481
+ </div>
482
+
483
+ <div class="card">
484
+ <h2 style="margin:0 0 8px 0;">Albums</h2>
485
+ <div class="small">Add/edit albums here. Polling loop uses this list.</div>
486
+ <table>
487
+ <thead>
488
+ <tr>
489
+ <th style="width:28%;">Album Token</th>
490
+ <th>Publisher</th>
491
+ <th>Category</th>
492
+ </tr>
493
+ </thead>
494
+ <tbody>
495
+ __ALBUM_ROWS__
496
+ <tr>
497
+ <td><input id="new_token" placeholder="New token"></td>
498
+ <td><input id="new_publisher" placeholder="Publisher"></td>
499
+ <td>
500
+ <div class="grid2">
501
+ <input id="new_category" placeholder="Category">
502
+ <button onclick="addAlbum()">Add Album</button>
503
+ </div>
504
+ </td>
505
+ </tr>
506
+ </tbody>
507
+ </table>
508
+ </div>
509
+
510
+ <div class="card">
511
+ <h2 style="margin:0 0 8px 0;">Videos</h2>
512
+ <div class="small">Edits save to index.json instantly on field change.</div>
513
+ <table>
514
+ <thead>
515
+ <tr>
516
+ <th style="width:13%;">ID</th>
517
+ <th style="width:16%;">Name</th>
518
+ <th style="width:16%;">Upload Date</th>
519
+ <th style="width:10%;">Category</th>
520
+ <th style="width:12%;">Publisher</th>
521
+ <th style="width:18%;">Thumbnail</th>
522
+ <th style="width:15%;">Source Album</th>
523
+ </tr>
524
+ </thead>
525
+ <tbody>
526
+ __VIDEO_ROWS__
527
+ </tbody>
528
+ </table>
529
+ </div>
530
+
531
  <script>
532
+ async function postJSON(url, obj) {
533
+ const r = await fetch(url, {
534
+ method: "POST",
535
+ headers: { "Content-Type": "application/json" },
536
+ body: JSON.stringify(obj),
537
+ });
538
+ return r.json();
539
+ }
540
+
541
+ // autosave video fields
542
+ document.addEventListener("change", async (e) => {
543
+ const t = e.target;
544
+ if (!t || !t.dataset || !t.dataset.id) return;
545
+
546
+ const id = t.dataset.id;
547
+ const field = t.dataset.field;
548
+ const value = t.value;
549
+
550
+ await postJSON("/admin/update", { id, field, value });
551
+ });
552
+
553
+ // autosave album fields
554
+ document.addEventListener("change", async (e) => {
555
+ const t = e.target;
556
+ if (!t || !t.dataset || !t.dataset.album) return;
557
+
558
+ const token = t.dataset.album;
559
+ const field = t.dataset.field;
560
+ const value = t.value;
561
+
562
+ await postJSON("/admin/albums/update", { token, field, value });
563
+ });
564
+
565
+ async function addAlbum() {
566
+ const token = document.getElementById("new_token").value.trim();
567
+ const publisher = document.getElementById("new_publisher").value.trim();
568
+ const category = document.getElementById("new_category").value.trim();
569
+ if (!token) return alert("Token required");
570
+
571
+ const out = await postJSON("/admin/albums/add", { token, publisher, category });
572
+ if (out && out.ok) location.reload();
573
+ else alert(out.error || "failed");
574
+ }
575
  </script>
576
+ </body>
577
+ </html>
578
+ """
579
+
580
+ @app.get("/admin", response_class=HTMLResponse)
581
+ async def admin_dashboard(req: Request):
582
+ if not is_admin(req):
583
+ return RedirectResponse("/admin/login", status_code=302)
584
+
585
+ async with INDEX_LOCK:
586
+ idx = load_index_sync()
587
+ cfg = await load_admin_config()
588
+
589
+ # Build album rows
590
+ ap = cfg.get("album_publishers", {})
591
+ ac = cfg.get("album_categories", {})
592
+ album_tokens = sorted(set(ap.keys()) | set(ac.keys()))
593
+
594
+ album_rows = ""
595
+ for token in album_tokens:
596
+ album_rows += (
597
+ "<tr>"
598
+ f"<td><input data-album=\"{esc(token)}\" data-field=\"token\" value=\"{esc(token)}\" disabled></td>"
599
+ f"<td><input data-album=\"{esc(token)}\" data-field=\"publisher\" value=\"{esc(ap.get(token, ''))}\"></td>"
600
+ f"<td><input data-album=\"{esc(token)}\" data-field=\"category\" value=\"{esc(ac.get(token, ''))}\"></td>"
601
+ "</tr>"
602
+ )
603
+
604
+ # Build video rows
605
+ video_rows = ""
606
+ for v in idx.get("videos", []):
607
+ vid = esc(v.get("id", ""))
608
+ video_rows += (
609
+ "<tr>"
610
+ f"<td class='small'>{vid}</td>"
611
+ f"<td><input data-id=\"{vid}\" data-field=\"name\" value=\"{esc(v.get('name',''))}\"></td>"
612
+ f"<td><input data-id=\"{vid}\" data-field=\"upload_date\" value=\"{esc(v.get('upload_date',''))}\"></td>"
613
+ f"<td><input data-id=\"{vid}\" data-field=\"category\" value=\"{esc(v.get('category',''))}\"></td>"
614
+ f"<td><input data-id=\"{vid}\" data-field=\"publisher\" value=\"{esc(v.get('publisher',''))}\"></td>"
615
+ f"<td><input data-id=\"{vid}\" data-field=\"thumbnail\" value=\"{esc(v.get('thumbnail',''))}\"></td>"
616
+ f"<td><input data-id=\"{vid}\" data-field=\"source_album\" value=\"{esc(v.get('source_album',''))}\"></td>"
617
+ "</tr>"
618
+ )
619
+
620
+ page = ADMIN_TEMPLATE.replace("__ALBUM_ROWS__", album_rows).replace("__VIDEO_ROWS__", video_rows)
621
+ return HTMLResponse(page)
622
 
623
  # ==================================================
624
+ # ADMIN: UPDATE VIDEO METADATA
625
  # ==================================================
626
+ ALLOWED_VIDEO_FIELDS = {
627
+ "name", "upload_date", "category", "publisher", "thumbnail", "source_album",
628
+ # (Optional) allow these if you want:
629
+ # "video_url",
630
+ }
631
+
632
  @app.post("/admin/update")
633
  async def admin_update(req: Request, payload: dict):
634
  if not is_admin(req):
635
+ return JSONResponse({"error": "unauthorized"}, status_code=403)
636
+
637
+ vid = str(payload.get("id", "")).strip()
638
+ field = str(payload.get("field", "")).strip()
639
+ value = payload.get("value", "")
640
+
641
+ if not vid or field not in ALLOWED_VIDEO_FIELDS:
642
+ return JSONResponse({"error": "bad request"}, status_code=400)
643
+
644
+ async with INDEX_LOCK:
645
+ idx = load_index_sync()
646
+ for v in idx.get("videos", []):
647
+ if v.get("id") == vid:
648
+ v[field] = value
649
+ save_index_sync(idx)
650
+ return {"ok": True}
651
+
652
+ return JSONResponse({"error": "video not found"}, status_code=404)
653
+
654
+ # ==================================================
655
+ # ADMIN: ALBUM MANAGEMENT (persisted)
656
+ # ==================================================
657
+ @app.post("/admin/albums/add")
658
+ async def admin_albums_add(req: Request, payload: dict):
659
+ if not is_admin(req):
660
+ return JSONResponse({"error": "unauthorized"}, status_code=403)
661
+
662
+ token = str(payload.get("token", "")).strip()
663
+ publisher = str(payload.get("publisher", "")).strip() or "Unknown"
664
+ category = str(payload.get("category", "")).strip() or "Uncategorized"
665
+
666
+ if not token:
667
+ return JSONResponse({"error": "token required"}, status_code=400)
668
+
669
+ cfg = await load_admin_config()
670
+ cfg.setdefault("album_publishers", {})
671
+ cfg.setdefault("album_categories", {})
672
+
673
+ cfg["album_publishers"][token] = publisher
674
+ cfg["album_categories"][token] = category
675
+
676
+ await write_admin_config(cfg)
677
+ await refresh_album_maps_from_disk()
678
+ await backfill_index_categories()
679
+
680
+ return {"ok": True}
681
+
682
+ @app.post("/admin/albums/update")
683
+ async def admin_albums_update(req: Request, payload: dict):
684
+ if not is_admin(req):
685
+ return JSONResponse({"error": "unauthorized"}, status_code=403)
686
+
687
+ token = str(payload.get("token", "")).strip()
688
+ field = str(payload.get("field", "")).strip()
689
+ value = str(payload.get("value", "")).strip()
690
+
691
+ if not token or field not in ("publisher", "category"):
692
+ return JSONResponse({"error": "bad request"}, status_code=400)
693
+
694
+ cfg = await load_admin_config()
695
+ cfg.setdefault("album_publishers", {})
696
+ cfg.setdefault("album_categories", {})
697
+
698
+ if field == "publisher":
699
+ cfg["album_publishers"][token] = value or "Unknown"
700
+ else:
701
+ cfg["album_categories"][token] = value or "Uncategorized"
702
+
703
+ await write_admin_config(cfg)
704
+ await refresh_album_maps_from_disk()
705
+ await backfill_index_categories()
706
 
707
+ return {"ok": True}
 
 
 
 
 
 
708
 
709
  # ==================================================
710
+ # STATIC MEDIA
711
  # ==================================================
712
  app.mount("/media", StaticFiles(directory=VIDEO_DIR), name="media")