243 lines
10 KiB
Python
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"]}
|