banyaro/backend/routes/media.py

243 lines
10 KiB
Python

"""
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"]}