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:
parent
203da50e1d
commit
e86d89f3d9
12 changed files with 947 additions and 59 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue