SalexAI commited on
Commit
921564c
·
verified ·
1 Parent(s): 28f0042

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +108 -98
app.py CHANGED
@@ -11,28 +11,12 @@ import secrets
11
  from datetime import datetime, timezone
12
 
13
  # ==================================================
14
- # App setup
15
- # ==================================================
16
- app = FastAPI()
17
- logging.basicConfig(level=logging.INFO)
18
-
19
- app.add_middleware(
20
- CORSMiddleware,
21
- allow_origins=["*"],
22
- allow_methods=["*"],
23
- allow_headers=["*"],
24
- )
25
-
26
- # ==================================================
27
- # Admin config
28
  # ==================================================
29
  ADMIN_PIN = os.environ.get("ADMIN_PIN", "4286") # CHANGE THIS
30
  ADMIN_COOKIE = "admin_session"
31
  ADMIN_SESSIONS = set()
32
 
33
- # ==================================================
34
- # Storage
35
- # ==================================================
36
  DATA_ROOT = os.environ.get("PERSISTENT_DIR", "/data")
37
  VIDEO_DIR = os.path.join(DATA_ROOT, "videos")
38
  INDEX_FILE = os.path.join(DATA_ROOT, "index.json")
@@ -40,7 +24,20 @@ INDEX_FILE = os.path.join(DATA_ROOT, "index.json")
40
  os.makedirs(VIDEO_DIR, exist_ok=True)
41
 
42
  # ==================================================
43
- # Album mappings
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  # ==================================================
45
  ALBUM_PUBLISHERS = {
46
  "B2c5n8hH4uWRoAW": "Alex Rose",
@@ -61,33 +58,28 @@ ALBUM_CATEGORIES = {
61
  }
62
 
63
  # ==================================================
64
- # Admin helpers
65
- # ==================================================
66
- def is_admin(request: Request) -> bool:
67
- return request.cookies.get(ADMIN_COOKIE) in ADMIN_SESSIONS
68
-
69
- # ==================================================
70
- # HTTP client
71
  # ==================================================
72
  async def get_client():
73
  if not hasattr(app.state, "client"):
74
- app.state.client = httpx.AsyncClient(timeout=20)
75
  return app.state.client
76
 
77
  # ==================================================
78
- # Base62 helpers
79
  # ==================================================
80
- BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
81
- BASE62_MAP = {c: i for i, c in enumerate(BASE62)}
 
82
 
83
- def base62_to_int(s: str) -> int:
84
  n = 0
85
- for c in s:
86
- n = n * 62 + BASE62_MAP[c]
87
  return n
88
 
89
  # ==================================================
90
- # iCloud helpers
91
  # ==================================================
92
  ICLOUD_HEADERS = {
93
  "Origin": "https://www.icloud.com",
@@ -96,12 +88,17 @@ ICLOUD_HEADERS = {
96
  ICLOUD_PAYLOAD = '{"streamCtag":null}'
97
 
98
  async def get_base_url(token: str) -> str:
99
- n = base62_to_int(token[1:3] if token[0] != "A" else token[1])
100
  return f"https://p{n:02d}-sharedstreams.icloud.com/{token}/sharedstreams/"
101
 
102
  async def get_redirected_base_url(base_url: str, token: str) -> str:
103
  client = await get_client()
104
- r = await client.post(base_url + "webstream", headers=ICLOUD_HEADERS, data=ICLOUD_PAYLOAD, follow_redirects=False)
 
 
 
 
 
105
  if r.status_code == 330:
106
  host = r.json()["X-Apple-MMe-Host"]
107
  return f"https://{host}/{token}/sharedstreams/"
@@ -109,19 +106,20 @@ async def get_redirected_base_url(base_url: str, token: str) -> str:
109
 
110
  async def post_json(path: str, base_url: str, payload: str):
111
  client = await get_client()
112
- r = await client.post(base_url + path, headers=ICLOUD_HEADERS, data=payload)
113
  r.raise_for_status()
114
  return r.json()
115
 
116
- async def get_metadata(base_url):
117
  return (await post_json("webstream", base_url, ICLOUD_PAYLOAD)).get("photos", [])
118
 
119
- async def get_asset_urls(base_url, guids):
120
- payload = json.dumps({"photoGuids": guids})
121
- return (await post_json("webasseturls", base_url, payload)).get("items", {})
 
122
 
123
  # ==================================================
124
- # Index helpers
125
  # ==================================================
126
  def load_index():
127
  if not os.path.exists(INDEX_FILE):
@@ -134,7 +132,7 @@ def save_index(data):
134
  json.dump(data, f, indent=2)
135
 
136
  # ==================================================
137
- # Downloader
138
  # ==================================================
139
  async def download_file(url, path):
140
  client = await get_client()
@@ -145,12 +143,12 @@ async def download_file(url, path):
145
  f.write(chunk)
146
 
147
  # ==================================================
148
- # Poll album
149
  # ==================================================
150
  async def poll_album(token):
151
  base = await get_redirected_base_url(await get_base_url(token), token)
152
- meta = await get_metadata(base)
153
- guids = [p["photoGuid"] for p in meta]
154
  assets = await get_asset_urls(base, guids)
155
 
156
  index = load_index()
@@ -159,15 +157,18 @@ async def poll_album(token):
159
  album_dir = os.path.join(VIDEO_DIR, token)
160
  os.makedirs(album_dir, exist_ok=True)
161
 
162
- for p in meta:
163
  if p.get("mediaAssetType", "").lower() != "video":
164
  continue
165
  vid = p["photoGuid"]
166
  if vid in known:
167
  continue
168
 
169
- derivatives = p.get("derivatives", {})
170
- best = max((d for d in derivatives.values() if d), key=lambda d: int(d.get("fileSize") or 0), default=None)
 
 
 
171
  if not best:
172
  continue
173
 
@@ -176,23 +177,25 @@ async def poll_album(token):
176
  continue
177
 
178
  video_path = os.path.join(album_dir, f"{vid}.mp4")
179
- await download_file(f"https://{asset['url_location']}{asset['url_path']}", video_path)
 
 
180
 
181
  thumb = ""
182
- pf = derivatives.get("PosterFrame")
183
- if pf and pf.get("checksum") in assets:
184
- thumb_path = os.path.join(album_dir, f"{vid}.jpg")
185
- await download_file(
186
- f"https://{assets[pf['checksum']]['url_location']}{assets[pf['checksum']]['url_path']}",
187
- thumb_path
188
- )
189
- thumb = f"/media/{token}/{vid}.jpg"
190
 
191
  index["videos"].append({
192
  "id": vid,
193
  "name": p.get("caption") or "Untitled",
194
  "video_url": f"/media/{token}/{vid}.mp4",
195
- "thumbnail": thumb,
196
  "upload_date": p.get("creationDate") or datetime.now(timezone.utc).isoformat(),
197
  "category": ALBUM_CATEGORIES.get(token, "Uncategorized"),
198
  "publisher": ALBUM_PUBLISHERS.get(token, "Unknown"),
@@ -201,38 +204,41 @@ async def poll_album(token):
201
 
202
  save_index(index)
203
 
204
- # ==================================================
205
- # Startup polling
206
- # ==================================================
207
  @app.on_event("startup")
208
- async def start():
209
  async def loop():
210
  while True:
211
- for t in ALBUM_PUBLISHERS:
212
  try:
213
- await poll_album(t)
214
  except Exception:
215
- logging.exception("Poll 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 login
 
 
 
 
 
 
228
  # ==================================================
229
  @app.get("/admin/login", response_class=HTMLResponse)
230
  async def admin_login_page():
231
  return """
232
- <form method="post" style="font-family:sans-serif;padding:40px">
233
- <h2>Admin Login</h2>
234
- <input name="pin" type="password" placeholder="PIN">
235
- <button>Enter</button>
236
  </form>
237
  """
238
 
@@ -240,18 +246,18 @@ async def admin_login_page():
240
  async def admin_login(pin: str = Form(...)):
241
  if pin != ADMIN_PIN:
242
  return HTMLResponse("Wrong PIN", status_code=401)
243
- s = secrets.token_hex(16)
244
- ADMIN_SESSIONS.add(s)
245
  r = RedirectResponse("/admin", 302)
246
- r.set_cookie(ADMIN_COOKIE, s, httponly=True)
247
  return r
248
 
249
  # ==================================================
250
- # Admin dashboard
251
  # ==================================================
252
  @app.get("/admin", response_class=HTMLResponse)
253
- async def admin(request: Request):
254
- if not is_admin(request):
255
  return RedirectResponse("/admin/login", 302)
256
 
257
  idx = load_index()
@@ -259,48 +265,52 @@ async def admin(request: Request):
259
  for v in idx["videos"]:
260
  rows += f"""
261
  <tr>
262
- <td>{v['id']}</td>
263
- <td><input value="{v['name']}" data-id="{v['id']}" data-field="name"></td>
264
- <td><input value="{v['upload_date']}" data-id="{v['id']}" data-field="upload_date"></td>
265
- <td><input value="{v['category']}" data-id="{v['id']}" data-field="category"></td>
266
- <td><input value="{v['publisher']}" data-id="{v['id']}" data-field="publisher"></td>
267
- <td><input value="{v['thumbnail']}" data-id="{v['id']}" data-field="thumbnail"></td>
268
  </tr>
269
  """
270
 
271
  return f"""
272
  <table border=1>
273
- <tr><th>ID</th><th>Name</th><th>Date</th><th>Category</th><th>Publisher</th><th>Thumbnail</th></tr>
274
- {rows}
275
  </table>
276
  <script>
277
  document.querySelectorAll("input").forEach(i=>{
278
- i.onchange=()=>fetch("/admin/update", {{
279
- method:"POST",
280
- headers:{{"Content-Type":"application/json"}},
281
- body:JSON.stringify({{id:i.dataset.id,field:i.dataset.field,value:i.value}})
282
- }});
283
- });
 
 
 
 
284
  </script>
285
  """
286
 
287
  # ==================================================
288
- # Admin update
289
  # ==================================================
290
  @app.post("/admin/update")
291
- async def admin_update(request: Request, payload: dict):
292
- if not is_admin(request):
293
- return JSONResponse({"error": "unauthorized"}, 403)
294
 
295
  idx = load_index()
296
  for v in idx["videos"]:
297
  if v["id"] == payload["id"]:
298
  v[payload["field"]] = payload["value"]
299
  save_index(idx)
300
- return {"ok": True}
301
- return {"error": "not found"}
302
 
303
  # ==================================================
304
- # Static media
305
  # ==================================================
306
  app.mount("/media", StaticFiles(directory=VIDEO_DIR), name="media")
 
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")
 
24
  os.makedirs(VIDEO_DIR, exist_ok=True)
25
 
26
  # ==================================================
27
+ # APP SETUP
28
+ # ==================================================
29
+ app = FastAPI()
30
+ logging.basicConfig(level=logging.INFO)
31
+
32
+ app.add_middleware(
33
+ CORSMiddleware,
34
+ allow_origins=["*"],
35
+ allow_methods=["*"],
36
+ allow_headers=["*"],
37
+ )
38
+
39
+ # ==================================================
40
+ # ALBUM METADATA
41
  # ==================================================
42
  ALBUM_PUBLISHERS = {
43
  "B2c5n8hH4uWRoAW": "Alex Rose",
 
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"
73
+ )}
74
 
75
+ def base62_to_int(token: str) -> int:
76
  n = 0
77
+ for c in token:
78
+ n = n * 62 + BASE_62_MAP[c]
79
  return n
80
 
81
  # ==================================================
82
+ # ICLOUD HELPERS
83
  # ==================================================
84
  ICLOUD_HEADERS = {
85
  "Origin": "https://www.icloud.com",
 
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:
95
  client = await get_client()
96
+ r = await client.post(
97
+ f"{base_url}webstream",
98
+ headers=ICLOUD_HEADERS,
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/"
 
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):
 
132
  json.dump(data, f, indent=2)
133
 
134
  # ==================================================
135
+ # DOWNLOADER
136
  # ==================================================
137
  async def download_file(url, path):
138
  client = await get_client()
 
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()
 
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
+ )
172
  if not best:
173
  continue
174
 
 
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"),
 
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
 
 
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()
 
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")