""" Media-Registry — iOS-Voll-App M0 (iCloud-Hybrid, Plan: banyaro-ios/PLAN_VOLLAPP.md). Originale liegen in der privaten CloudKit-DB des Nutzers (die App lädt sie dorthin), der Server speichert nur Previews. Die Registry ist die Quelle der Wahrheit über Existenz und Speicherort jedes Mediums; bei storage='icloud' sieht der Server die Original-Bytes nie (Apple erlaubt Server-to-Server-Zugriff nur auf Public-DBs). Zwei Kontext-Modi: - diary: Phantom-Original-URL (Datei existiert nicht) + echtes _preview.webp bzw. _thumb.jpg daneben. Original-Request → 404 + {"storage":"icloud"} (serve_media in main.py), Web fällt via onerror aufs Preview zurück. - route: Das 800px-Preview wird ALS Datei unter der foto_url gespeichert — Routen-Fotos haben keine Preview-Konvention, Web bleibt unverändert. Geteilte Inhalte (GassiTreffen-Fotos, Forum, Challenges) bleiben bewusst komplett auf dem Server — fremde Nutzer können nicht in eine private iCloud schauen. Quota-Fallback: Ist die iCloud des Nutzers voll (CKError.quotaExceeded), lädt die App das Original klassisch über POST /api/media/{id}/original nach → storage='server'. """ import asyncio import json import os import uuid from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from pydantic import BaseModel from auth import get_current_user from database import db from media_utils import ( convert_media, generate_preview, preview_url_from, safe_media_path, validate_upload, ) router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") _PREVIEW_MAX_BYTES = 2 * 1024 * 1024 # Previews/Poster sind ≤800px-JPEGs _CONTEXTS = {"diary", "route"} _SUBDIR = {"diary": "diary", "route": "routes"} def _own_diary_entry(conn, entry_id: int, user_id: int): """Eintrag gehört dem User (direkt über dog_id oder via diary_dogs).""" return conn.execute( """SELECT d.id FROM diary d WHERE d.id=? AND ( d.dog_id IN (SELECT id FROM dogs WHERE user_id=?) OR EXISTS (SELECT 1 FROM diary_dogs dd JOIN dogs g ON g.id = dd.dog_id WHERE dd.diary_id = d.id AND g.user_id=?))""", (entry_id, user_id, user_id) ).fetchone() def _own_registry_row(conn, media_id: int, user_id: int): row = conn.execute( "SELECT * FROM media_registry WHERE id=? AND user_id=?", (media_id, user_id) ).fetchone() if not row: raise HTTPException(404, "Medium nicht gefunden.") return row def _write_bytes(path: str, data: bytes) -> None: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "wb") as f: f.write(data) @router.post("/register", status_code=201) async def register_media( context: str = Form(...), context_id: int = Form(...), kind: str = Form("image"), img_width: int | None = Form(None), img_height: int | None = Form(None), gps_lat: float | None = Form(None), gps_lon: float | None = Form(None), bytes_original: int | None = Form(None), preview: UploadFile = File(...), user=Depends(get_current_user), ): """Registriert ein iCloud-Medium: Metadaten + Preview zum Server, Original → CloudKit.""" if context not in _CONTEXTS: raise HTTPException(422, "Unbekannter Kontext.") if kind not in ("image", "video"): raise HTTPException(422, "kind muss image oder video sein.") if context == "route" and kind != "image": raise HTTPException(422, "Routen-Fotos sind Bilder.") raw = await preview.read() if len(raw) > _PREVIEW_MAX_BYTES: raise HTTPException(413, "Preview zu groß (max 2 MB).") try: validate_upload(raw, preview.filename or "preview.jpg") except ValueError as e: raise HTTPException(415, str(e)) prev_ext = os.path.splitext(preview.filename or "")[1].lower() or ".jpg" with db() as conn: if context == "diary": if not _own_diary_entry(conn, context_id, user["id"]): raise HTTPException(404, "Eintrag nicht gefunden.") else: row = conn.execute( "SELECT user_id FROM routes WHERE id=?", (context_id,) ).fetchone() if not row: raise HTTPException(404, "Route nicht gefunden.") if row["user_id"] != user["id"]: raise HTTPException(403, "Nicht berechtigt.") orig_ext = ".jpg" if kind == "image" else ".mp4" filename = f"{context}_{context_id}_{uuid.uuid4().hex[:8]}{orig_ext}" subdir = _SUBDIR[context] url = f"/media/{subdir}/{filename}" disk = os.path.join(MEDIA_DIR, subdir, filename) base = os.path.splitext(disk)[0] loop = asyncio.get_event_loop() if context == "diary": if kind == "image": webp = await loop.run_in_executor(None, lambda: generate_preview(raw, prev_ext)) if not webp: raise HTTPException(415, "Preview konnte nicht verarbeitet werden.") await loop.run_in_executor(None, lambda: _write_bytes(base + "_preview.webp", webp)) else: # Poster-Frame des Videos — gleiche Konvention wie extract_video_thumb() await loop.run_in_executor(None, lambda: _write_bytes(base + "_thumb.jpg", raw)) else: # route: das Preview wird die Datei selbst (Web kennt dort keine Previews) await loop.run_in_executor(None, lambda: _write_bytes(disk, raw)) with db() as conn: cur = conn.execute( "INSERT INTO media_registry (user_id, url, storage, ck_state, kind, context, context_id, bytes_original) " "VALUES (?,?,?,?,?,?,?,?)", (user["id"], url, "icloud", "pending", kind, context, context_id, bytes_original) ) rid = cur.lastrowid ck_name = f"media-{rid}" conn.execute("UPDATE media_registry SET ck_record_name=? WHERE id=?", (ck_name, rid)) if context == "diary": max_order = conn.execute( "SELECT COALESCE(MAX(sort_order), -1) FROM diary_media WHERE diary_id=?", (context_id,) ).fetchone()[0] # Erstes Item eines Eintrags wird automatisch Cover (wie diary.upload_media) is_cover = 1 if max_order == -1 else 0 cur2 = conn.execute( "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover, img_width, img_height) " "VALUES (?,?,?,?,?,?,?)", (context_id, url, kind, max_order + 1, is_cover, img_width, img_height) ) dm_id = cur2.lastrowid # GPS vom Gerät — wie der EXIF-Pfad in diary.upload_media, aber ohne # Wetter/POI-Nachladen (iOS-Einträge bringen i.d.R. Track-GPS mit). if gps_lat is not None and gps_lon is not None: existing = conn.execute( "SELECT gps_lat FROM diary WHERE id=?", (context_id,) ).fetchone() if existing and existing["gps_lat"] is None: conn.execute( "UPDATE diary SET gps_lat=?, gps_lon=? WHERE id=?", (gps_lat, gps_lon, context_id) ) return {"id": dm_id, "url": url, "preview_url": preview_url_from(url), "media_type": kind, "sort_order": max_order + 1, "is_cover": is_cover, "media_id": rid, "ck_record_name": ck_name} # route: foto_urls-Append wie routen.add_route_photo row = conn.execute("SELECT foto_urls FROM routes WHERE id=?", (context_id,)).fetchone() urls = json.loads(dict(row)["foto_urls"] or "[]") urls.append(url) conn.execute("UPDATE routes SET foto_urls=? WHERE id=?", (json.dumps(urls), context_id)) return {"foto_url": url, "foto_urls": urls, "media_id": rid, "ck_record_name": ck_name} @router.get("/mine") async def my_media(user=Depends(get_current_user)): """Sync-Grundlage der App: sie gleicht ihre CloudKit-Zone gegen diese Liste ab und löscht verwaiste CKRecords (Web-Deletes kennt CloudKit sonst nicht).""" with db() as conn: rows = conn.execute( "SELECT id, url, storage, ck_record_name, ck_state, kind, context, context_id, created_at " "FROM media_registry WHERE user_id=? ORDER BY id", (user["id"],) ).fetchall() return [dict(r) for r in rows] class ConfirmBody(BaseModel): ck_record_name: str | None = None @router.patch("/{media_id}") async def confirm_media(media_id: int, body: ConfirmBody, user=Depends(get_current_user)): """Bestätigt den erfolgreichen CloudKit-Upload des Originals.""" with db() as conn: row = _own_registry_row(conn, media_id, user["id"]) ck_name = body.ck_record_name or row["ck_record_name"] conn.execute( "UPDATE media_registry SET ck_state='confirmed', ck_record_name=? WHERE id=?", (ck_name, media_id) ) return {"id": media_id, "storage": "icloud", "ck_state": "confirmed", "ck_record_name": ck_name} @router.post("/{media_id}/original", status_code=201) async def upload_original(media_id: int, file: UploadFile = File(...), user=Depends(get_current_user)): """Quota-Fallback: iCloud des Nutzers voll → Original klassisch zum Server. Schreibt die Datei an die registrierte URL (diary: füllt die Phantom-URL, route: ersetzt das Preview durch das Original).""" with db() as conn: row = _own_registry_row(conn, media_id, user["id"]) raw = await file.read() try: validate_upload(raw, file.filename or "") except ValueError as e: raise HTTPException(415, str(e)) loop = asyncio.get_event_loop() raw, _ext = await loop.run_in_executor( None, lambda: convert_media(raw, file.filename or "") ) disk = safe_media_path(MEDIA_DIR, row["url"]) if not disk: raise HTTPException(400, "Ungültiger Medienpfad.") await loop.run_in_executor(None, lambda: _write_bytes(disk, raw)) with db() as conn: conn.execute( "UPDATE media_registry SET storage='server', ck_state='none' WHERE id=?", (media_id,) ) return {"id": media_id, "storage": "server", "url": row["url"]}