Notiz-Medien & Sprachnachrichten: Fotos/Videos/Dateien + Audio an Notizen

Wiederverwendbarer UI.noteMediaAttacher für beide Notiz-Stellen (UI.noteModal
+ Notizblock-Seite). note_media-Tabelle + POST/DELETE /api/notes/{id}/media
(vor der gierigen /{parent_type}/{parent_id}-Route). Audio per MediaRecorder,
serverseitig nach m4a/AAC transkodiert (ffmpeg) — iOS spielt Chrome-Opus-webm
nicht ab. UI.lightbox global eingeführt. Mikrofon-Policy microphone=(self) +
CSP media-src 'self' blob:, Datenschutz v6. Disk-Cleanup für note_media bei
Notiz-, Account- und Admin-User-Delete. Reine Medien-Notiz ohne Text erlaubt.
noteModal-Bug gefixt: notes.get() liefert Array -> existing[0] statt
existing?.id (verhinderte Bearbeiten, erzeugte Duplikate). 12 neue Tests.

admin.py enthält außerdem KI-Vision-Statusfelder aus paralleler Arbeit
(nicht sauber trennbar ohne interaktives Staging).
This commit is contained in:
rene 2026-06-14 20:22:35 +02:00
parent 203da50e1d
commit e86d89f3d9
12 changed files with 947 additions and 59 deletions

View file

@ -483,6 +483,16 @@ async def delete_user(uid: int, user=Depends(require_admin)):
conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,))
conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,))
conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,))
# Notiz-Medien: erst Dateien von Disk, dann DB-Zeilen (note_media + notes).
import os as _os
from media_utils import delete_media_files
_nm_urls = [r["url"] for r in conn.execute(
"SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?",
(uid,)
).fetchall()]
delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _nm_urls)
conn.execute("DELETE FROM note_media WHERE note_id IN (SELECT id FROM notes WHERE user_id=?)", (uid,))
conn.execute("DELETE FROM notes WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
_audit(conn, user, "user_delete", f"user:{uid} ({target['name']})")
@ -728,7 +738,7 @@ async def ki_history(user=Depends(require_mod)):
@router.get("/ki/status")
async def ki_status(user=Depends(require_mod)):
import httpx
from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, ANTHROPIC_KEY
from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, VISION_MODEL, ANTHROPIC_KEY
result = {
"mode": KI_MODE,
@ -737,6 +747,7 @@ async def ki_status(user=Depends(require_mod)):
"local_reachable": False,
"local_model_loaded": None,
"cloud_model": CLOUD_MODEL,
"vision_model": VISION_MODEL,
"cloud_key_set": bool(ANTHROPIC_KEY),
}
@ -944,7 +955,7 @@ async def wiki_enrich(data: WikiEnrichBody, user=Depends(require_mod)):
async def wiki_evaluate(sample: int = 20, user=Depends(require_mod)):
from scraper.breed_evaluator import evaluate_enrichment
sample = max(5, min(sample, 50))
return await evaluate_enrichment(sample_size=sample)
return await evaluate_enrichment(sample_size=sample, user_id=user["id"])
# ------------------------------------------------------------------

View file

@ -1,24 +1,33 @@
"""BAN YARO — Notizen Routes"""
import os
import json
import uuid
import asyncio
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from pydantic import BaseModel, Field
from typing import Optional, Any, List
from database import db
from auth import get_current_user
from timeutils import safe_client_time
from media_utils import (convert_media, extract_video_thumb, safe_media_path,
validate_upload, validate_audio, to_m4a,
generate_preview, get_image_size)
router = APIRouter()
logger = logging.getLogger(__name__)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class NoteCreate(BaseModel):
text: str = Field(..., min_length=1, max_length=5000)
# Leerer Text erlaubt: eine reine Medien-Notiz (nur Foto/Sprachnachricht)
# wird zuerst leer angelegt, dann werden die Medien angehängt.
text: str = Field("", max_length=5000)
meta_json: Optional[Any] = None
location_name: Optional[str] = Field(None, max_length=300)
parent_label: Optional[str] = Field(None, max_length=200)
@ -35,16 +44,81 @@ class NoteUpdate(BaseModel):
# ------------------------------------------------------------------
# Hilfsfunktionen
# ------------------------------------------------------------------
def _serialize(row) -> dict:
def _serialize(row, media_map: Optional[dict] = None) -> dict:
d = dict(row)
if d.get("meta_json") and isinstance(d["meta_json"], str):
try:
d["meta_json"] = json.loads(d["meta_json"])
except Exception:
pass
if media_map is not None:
d["media_items"] = media_map.get(d["id"], [])
return d
def _fetch_note_media(conn, note_ids: list) -> dict:
"""Lädt alle Medien zu den gegebenen Notiz-IDs als {note_id: [items]}."""
if not note_ids:
return {}
placeholders = ",".join("?" * len(note_ids))
rows = conn.execute(
f"""SELECT id, note_id, url, media_type, sort_order, img_width, img_height, duration_s
FROM note_media WHERE note_id IN ({placeholders})
ORDER BY sort_order, id""",
note_ids
).fetchall()
out: dict = {}
for r in rows:
out.setdefault(r["note_id"], []).append(dict(r))
return out
def _guess_note_media_type(content_type: str, filename: str) -> str:
ct = (content_type or "").lower()
if ct == "application/pdf" or (filename or "").lower().endswith(".pdf"):
return "pdf"
if ct.startswith("audio/"):
return "audio"
if ct.startswith("video/"):
return "video"
if ct.startswith("image/"):
return "image"
ext = os.path.splitext(filename or "")[1].lower()
if ext in {".mp4", ".mov", ".webm", ".m4v", ".avi"}:
return "video"
if ext in {".m4a", ".aac", ".mp3", ".ogg", ".oga", ".wav", ".opus"}:
return "audio"
if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"}:
return "image"
return "file"
def _delete_note_media_file(url: str) -> None:
"""Löscht eine Mediendatei + zugehörige Preview/Thumb-Leichen von Disk."""
file_path = safe_media_path(MEDIA_DIR, url)
if not file_path:
return
try:
os.remove(file_path)
except OSError:
pass
base = os.path.splitext(file_path)[0]
for leftover in (base + "_preview.webp", base + "_thumb.jpg"):
try:
os.remove(leftover)
except OSError:
pass
def _own_note(note_id: int, user_id: int, conn):
row = conn.execute(
"SELECT id FROM notes WHERE id=? AND user_id=?", (note_id, user_id)
).fetchone()
if not row:
raise HTTPException(404, "Notiz nicht gefunden.")
return row
# ------------------------------------------------------------------
# GET /api/notes — Gesamt-Notizblock mit Filtern
# Alias: GET /api/notes/all/0 (Rückwärtskompatibilität)
@ -97,8 +171,9 @@ async def list_all_notes_filtered(
f"SELECT * FROM notes WHERE {where} ORDER BY {order}",
params
).fetchall()
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
return [_serialize(r) for r in rows]
return [_serialize(r, media_map) for r in rows]
@router.get("/all/0")
@ -109,7 +184,8 @@ async def list_all_notes(user=Depends(get_current_user)):
"SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC",
(user["id"],)
).fetchall()
return [_serialize(r) for r in rows]
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
return [_serialize(r, media_map) for r in rows]
# ------------------------------------------------------------------
@ -169,6 +245,99 @@ async def ki_analyse(user=Depends(get_current_user)):
return {"suggestions": suggestions, "note_count": note_count}
# ------------------------------------------------------------------
# Medien-Anhänge an Notizen (Bild/Video/Audio/Datei)
# WICHTIG: Diese Routen MÜSSEN vor /{parent_type}/{parent_id} stehen,
# sonst matcht POST /123/media als parent_type=123, parent_id="media"!
# ------------------------------------------------------------------
@router.post("/{note_id}/media")
async def upload_note_media(note_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user)):
with db() as conn:
_own_note(note_id, user["id"], conn)
ct = (file.content_type or "").lower().split(";")[0].strip()
raw_data = await file.read()
loop = asyncio.get_event_loop()
if ct.startswith("audio/"):
media_type = "audio"
try:
validate_audio(raw_data, ct)
except ValueError as e:
raise HTTPException(415, str(e))
src_ext = os.path.splitext(file.filename or "")[1].lower() or ".webm"
raw_data, ext = await loop.run_in_executor(None, lambda: to_m4a(raw_data, src_ext))
else:
# Bild/Video/PDF/sonstige Datei — gleiche Pipeline wie Tagebuch.
try:
validate_upload(raw_data, file.filename or "")
except ValueError as e:
raise HTTPException(415, str(e))
media_type = _guess_note_media_type(ct, file.filename or "")
raw_data, ext = await loop.run_in_executor(
None, lambda: convert_media(raw_data, file.filename or "")
)
if not ext:
ext = ".bin"
filename = f"note_{note_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "notes", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
def _write_bytes(p: str, data: bytes) -> None:
with open(p, "wb") as f:
f.write(data)
await loop.run_in_executor(None, lambda: _write_bytes(path, raw_data))
img_size = None
if media_type == "video":
await loop.run_in_executor(None, lambda: extract_video_thumb(path))
elif media_type == "image":
preview_bytes = await loop.run_in_executor(None, lambda: generate_preview(raw_data, ext))
if preview_bytes:
preview_path = os.path.splitext(path)[0] + "_preview.webp"
await loop.run_in_executor(None, lambda: _write_bytes(preview_path, preview_bytes))
img_size = await loop.run_in_executor(None, lambda: get_image_size(raw_data))
media_url = f"/media/notes/{filename}"
with db() as conn:
max_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) FROM note_media WHERE note_id=?",
(note_id,)
).fetchone()[0]
conn.execute(
"""INSERT INTO note_media (note_id, url, media_type, sort_order, img_width, img_height)
VALUES (?,?,?,?,?,?)""",
(note_id, media_url, media_type, max_order + 1,
img_size[0] if img_size else None, img_size[1] if img_size else None)
)
row = conn.execute(
"""SELECT id, note_id, url, media_type, sort_order, img_width, img_height, duration_s
FROM note_media WHERE note_id=? ORDER BY id DESC LIMIT 1""",
(note_id,)
).fetchone()
return dict(row)
@router.delete("/{note_id}/media/{media_id}", status_code=204)
async def delete_note_media(note_id: int, media_id: int,
user=Depends(get_current_user)):
with db() as conn:
_own_note(note_id, user["id"], conn)
row = conn.execute(
"SELECT id, url FROM note_media WHERE id=? AND note_id=?",
(media_id, note_id)
).fetchone()
if not row:
raise HTTPException(404, "Medium nicht gefunden.")
_delete_note_media_file(row["url"])
conn.execute("DELETE FROM note_media WHERE id=?", (media_id,))
return None
# ------------------------------------------------------------------
# GET /api/notes/{parent_type}/{parent_id}
# parent_id kann ein Integer oder ein String-Schlüssel sein.
@ -184,7 +353,8 @@ async def list_notes(parent_type: str, parent_id: str,
ORDER BY created_at DESC""",
(user["id"], parent_type, parent_id)
).fetchall()
return [_serialize(r) for r in rows]
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
return [_serialize(r, media_map) for r in rows]
# ------------------------------------------------------------------
@ -193,9 +363,6 @@ async def list_notes(parent_type: str, parent_id: str,
@router.post("/{parent_type}/{parent_id}", status_code=201)
async def create_note(parent_type: str, parent_id: str, data: NoteCreate,
user=Depends(get_current_user)):
if not data.text.strip():
raise HTTPException(400, "Notiz darf nicht leer sein.")
meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None
now = safe_client_time(data.client_time)
@ -212,7 +379,7 @@ async def create_note(parent_type: str, parent_id: str, data: NoteCreate,
"SELECT * FROM notes WHERE user_id=? AND parent_type=? AND parent_id=? ORDER BY id DESC LIMIT 1",
(user["id"], parent_type, parent_id)
).fetchone()
return _serialize(row)
return _serialize(row, {}) # frisch erstellt → media_items=[]; Upload folgt separat
# ------------------------------------------------------------------
@ -230,8 +397,7 @@ async def update_note(note_id: int, data: NoteUpdate,
updates = {}
if data.text is not None:
if not data.text.strip():
raise HTTPException(400, "Notiz darf nicht leer sein.")
# Leer erlaubt — Medien können die Notiz tragen.
updates["text"] = data.text.strip()
if data.meta_json is not None:
updates["meta_json"] = json.dumps(data.meta_json)
@ -241,14 +407,16 @@ async def update_note(note_id: int, data: NoteUpdate,
updates["parent_label"] = data.parent_label
if not updates:
return _serialize(note)
media_map = _fetch_note_media(conn, [note_id])
return _serialize(note, media_map)
updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
set_clause = ", ".join(f"{k}=?" for k in updates)
values = list(updates.values()) + [note_id]
conn.execute(f"UPDATE notes SET {set_clause} WHERE id=?", values)
row = conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone()
return _serialize(row)
media_map = _fetch_note_media(conn, [note_id])
return _serialize(row, media_map)
# ------------------------------------------------------------------
@ -262,5 +430,8 @@ async def delete_note(note_id: int, user=Depends(get_current_user)):
).fetchone()
if not note:
raise HTTPException(404, "Notiz nicht gefunden.")
# Medien-Dateien von Disk räumen (FK-Cascade löscht nur die DB-Zeilen).
for m in conn.execute("SELECT url FROM note_media WHERE note_id=?", (note_id,)).fetchall():
_delete_note_media_file(m["url"])
conn.execute("DELETE FROM notes WHERE id=?", (note_id,))
return None

View file

@ -216,6 +216,16 @@ async def delete_account(user=Depends(get_current_user)):
if col in cols and (tbl, col) not in handled_fk_cols and (tbl, col) not in _ACTOR_COLUMNS:
conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,))
# note_media-Dateien von Disk räumen — der FK-Cascade beim users-DELETE
# entfernt nur die DB-Zeilen, nicht die Dateien.
import os as _os
from media_utils import delete_media_files
_note_media_urls = [r["url"] for r in conn.execute(
"SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?",
(uid,)
).fetchall()]
delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _note_media_urls)
# Räumt alle verbliebenen ON-DELETE-CASCADE-Tabellen automatisch ab.
conn.execute("DELETE FROM users WHERE id=?", (uid,))
return {"status": "deleted"}