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

@ -6,6 +6,9 @@ from typing import Tuple
_HEIC_EXTS = {".heic", ".heif"}
_VIDEO_EXTS = {".mov", ".avi", ".m4v"}
# Audio-Endungen, die bereits AAC in einem MP4-Container sind (iOS-Recorder
# liefert audio/mp4) — keine Transkodierung nötig.
_AUDIO_AAC_EXTS = {".m4a", ".aac", ".mp4"}
# Magic-Byte-Signaturen erlaubter Medientypen
_IMAGE_MAGIC = [
@ -51,6 +54,34 @@ def validate_upload(data: bytes, filename: str) -> None:
# HEIC, MOV, AVI, M4V: Pillow/FFmpeg prüfen beim Konvertieren
def validate_audio(data: bytes, content_type: str) -> None:
"""
Prüft Magic Bytes einer Audio-Datei gegen den vom Client gemeldeten content_type.
Wirft ValueError bei Mismatch. WICHTIG: Audio-WebM und Video-WebM teilen sich
dieselbe Magic-Byte-Signatur (Matroska) die Unterscheidung ist NUR über den
content_type möglich, deshalb diese eigene Funktion (validate_upload hat keinen).
"""
if not data:
raise ValueError("Leere Audiodatei.")
ct = (content_type or "").lower().split(";")[0].strip()
if ct in ("audio/mp4", "audio/aac", "audio/x-m4a", "audio/m4a"):
if not (len(data) >= 8 and data[4:8] in (b'ftyp', b'mdat', b'moov', b'free')):
raise ValueError("Datei ist kein gültiges MP4/AAC-Audio.")
elif ct == "audio/webm":
if not data[:4] == b'\x1a\x45\xdf\xa3':
raise ValueError("Datei ist kein gültiges WebM-Audio.")
elif ct in ("audio/ogg", "audio/opus"):
if not data[:4] == b'OggS':
raise ValueError("Datei ist kein gültiges Ogg-Audio.")
elif ct == "audio/mpeg":
if not (data[:3] == b'ID3' or (len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0)):
raise ValueError("Datei ist kein gültiges MP3.")
elif ct in ("audio/wav", "audio/x-wav", "audio/wave"):
if not (data[:4] == b'RIFF' and data[8:12] == b'WAVE'):
raise ValueError("Datei ist kein gültiges WAV.")
# Andere/unbekannte Audio-Typen: ffmpeg prüft beim Transkodieren.
def safe_media_path(media_dir: str, url: str) -> str | None:
"""
Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL.
@ -69,6 +100,21 @@ def safe_media_path(media_dir: str, url: str) -> str | None:
return candidate
def delete_media_files(media_dir: str, urls) -> None:
"""Löscht mehrere Mediendateien samt _preview.webp/_thumb.jpg-Leichen von Disk
(best-effort, Path-Traversal-sicher). Für Cascade-Cleanup bei Lösch-Operationen."""
for url in urls or []:
fp = safe_media_path(media_dir, url)
if not fp:
continue
base = os.path.splitext(fp)[0]
for path in (fp, base + "_preview.webp", base + "_thumb.jpg"):
try:
os.remove(path)
except OSError:
pass
def to_jpeg_if_heic(data: bytes, filename: str) -> Tuple[bytes, str]:
"""Convert HEIC/HEIF to JPEG; return (data, ext) unchanged for all other types."""
ext = os.path.splitext(filename or "")[1].lower()
@ -122,6 +168,44 @@ def to_mp4_if_needed(data: bytes, filename: str) -> Tuple[bytes, str]:
pass
def to_m4a(data: bytes, src_ext: str) -> Tuple[bytes, str]:
"""Transkodiert Audio nach m4a/AAC — universell abspielbar, auch auf iOS.
Nötig, weil Chrome/Firefox Opus-in-WebM/Ogg aufnehmen, das iOS Safari NICHT
abspielen kann. Bereits-AAC-Container (.m4a/.aac/.mp4, u.a. iOS-Recorder)
werden ohne Transkodierung durchgereicht. Bei ffmpeg-Fehler: Original zurück."""
ext = (src_ext or "").lower()
if not ext.startswith("."):
ext = ("." + ext) if ext else ".webm"
if ext in _AUDIO_AAC_EXTS:
return data, ".m4a"
src_path = dst_path = None
try:
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as src:
src.write(data)
src_path = src.name
dst_path = src_path[: -len(ext)] + ".m4a"
result = subprocess.run(
["ffmpeg", "-i", src_path,
"-vn", "-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
"-y", dst_path],
capture_output=True, timeout=120,
)
if result.returncode == 0:
with open(dst_path, "rb") as f:
return f.read(), ".m4a"
return data, ext
except Exception:
return data, ext
finally:
for p in [src_path, dst_path]:
if p:
try:
os.unlink(p)
except OSError:
pass
def extract_gps_from_exif(data: bytes) -> tuple | None:
"""EXIF-GPS aus Bilddaten lesen. Gibt (lat, lon) zurück oder None."""
try: