import os import subprocess import threading import time import uuid import json import re from datetime import datetime from pathlib import Path from zoneinfo import ZoneInfo from flask import Flask, abort, jsonify, render_template_string, request, send_from_directory, url_for, Response, stream_with_context, send_file from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- RECORDINGS_DIR = Path(os.environ.get("RECORDINGS_DIR", "/recordings")) FINISHED_DIR = Path(os.environ.get("FINISHED_DIR", "/finished")) INDEX_FILE = Path(os.environ.get("INDEX_FILE", str(FINISHED_DIR / ".event-audio-index.json"))) WAVEFORMS_DIR = Path(os.environ.get("WAVEFORMS_DIR", "/waveforms")) WEB_PORT = int(os.environ.get("WEB_PORT", "8080")) # --------------------------------------------------------------------------- # Branding / theme configuration SITE_NAME = os.environ.get("SITE_NAME", "Event Audio") SITE_SUBTITLE = os.environ.get("SITE_SUBTITLE", "Audiostream-Aufzeichnungen") PAGE_TITLE = os.environ.get("PAGE_TITLE", "Event Audio – Editor") PAGE_HEADING = os.environ.get("PAGE_HEADING", "Event Audio – Editor") EDIT_PAGE_TITLE = os.environ.get("EDIT_PAGE_TITLE", "Event Audio – Aufnahme bearbeiten") EDIT_PAGE_HEADING = os.environ.get("EDIT_PAGE_HEADING", "Aufnahme bearbeiten") THEME_FILE = Path(os.environ.get("THEME_FILE", "/config/theme.json")) LOGO_FILE = Path(os.environ["LOGO_FILE"]) if os.environ.get("LOGO_FILE") else None FAVICON_FILE = Path(os.environ["FAVICON_FILE"]) if os.environ.get("FAVICON_FILE") else None DEFAULT_THEME = {'background': '#111', 'surface': '#181818', 'surface_alt': '#222', 'surface_dark': '#0b0b0b', 'text': '#eee', 'text_secondary': '#ccc', 'text_muted': '#aaa', 'text_faint': '#777', 'border': '#333', 'border_subtle': '#2b2b2b', 'control': '#333', 'control_hover': '#444', 'primary': '#1769aa', 'primary_hover': '#1f7fc2', 'accent': '#ffcc33', 'success': '#173d24', 'success_text': '#b8f5c9', 'warning': '#6b4e00', 'danger': '#8b0000', 'danger_soft': '#4a2020', 'danger_soft_hover': '#6a2b2b', 'error': '#4a1b1b', 'error_text': '#ffd0d0', 'recording': '#b00000', 'recording_border': '#3a2222', 'selection': 'rgba(80,150,255,.16)', 'shadow': '#000', 'scroll_thumb': '#777', 'scroll_thumb_hover': '#999', 'scroll_thumb_active': '#aaa', 'live_surface': '#1d1111', 'table_header': '#252525', 'table_text': '#eee', 'footer': '#666', 'white': '#fff','surface_modal':'#202020','overlay':'rgba(0,0,0,.78)'} def load_theme(): """Load semantic theme colors from JSON, falling back to built-in defaults.""" theme = dict(DEFAULT_THEME) if THEME_FILE.is_file(): try: data = json.loads(THEME_FILE.read_text(encoding="utf-8")) colors = data.get("colors", data) if isinstance(data, dict) else {} if isinstance(colors, dict): for key in theme: value = colors.get(key) if isinstance(value, str) and re.fullmatch(r"(?:#[0-9a-fA-F]{3,8}|rgba?\([^)]*\))", value.strip()): theme[key] = value.strip() except Exception as exc: print(f"[branding] Theme konnte nicht geladen werden: {exc}", flush=True) return theme def theme_css(): theme = load_theme() return ":root {\n" + "\n".join( f" --color-{key.replace('_', '-')}: {value};" for key, value in theme.items() ) + "\n}" def branding_context(): return { "site_name": SITE_NAME, "site_subtitle": SITE_SUBTITLE, "page_title": PAGE_TITLE, "page_heading": PAGE_HEADING, "edit_page_title": EDIT_PAGE_TITLE, "edit_page_heading": EDIT_PAGE_HEADING, "theme_css": theme_css(), "logo_url": "/branding/logo" if LOGO_FILE and LOGO_FILE.is_file() else None, "favicon_url": "/branding/favicon" if FAVICON_FILE and FAVICON_FILE.is_file() else None, } NORMALIZE = os.environ.get("NORMALIZE", "true").lower() not in ("0", "false", "no") NORMALIZE_TARGET = os.environ.get("NORMALIZE_TARGET", "-16") NORMALIZE_TRUE_PEAK = os.environ.get("NORMALIZE_TRUE_PEAK", "-1.5") NORMALIZE_LRA = os.environ.get("NORMALIZE_LRA", "11") JOBS = {} JOBS_LOCK = threading.Lock() SSE_CLIENTS = set() SSE_LOCK = threading.Lock() FILE_OBSERVER = None # --------------------------------------------------------------------------- # Application # --------------------------------------------------------------------------- app = Flask(__name__) MAX_UPLOAD_MB = int(os.environ.get("MAX_UPLOAD_MB", "2048")) app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_MB * 1024 * 1024 # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def format_datetime(timestamp): return datetime.fromtimestamp(timestamp, ZoneInfo("Europe/Berlin")).strftime( "%d.%m.%Y %H:%M:%S" ) def format_size(size): if size < 1024: return f"{size} B" if size < 1024 ** 2: return f"{size / 1024:.1f} KB" if size < 1024 ** 3: return f"{size / 1024 ** 2:.1f} MB" return f"{size / 1024 ** 3:.2f} GB" def format_duration(seconds): if seconds is None: return "–" seconds = int(seconds) hours = seconds // 3600 minutes = (seconds % 3600) // 60 seconds = seconds % 60 if hours: return f"{hours}:{minutes:02d}:{seconds:02d}" return f"{minutes}:{seconds:02d}" def safe_filename(filename): if not filename or Path(filename).name != filename: return None if not filename.lower().endswith(".mp3"): return None return filename def clean_new_filename(filename): """Validate and normalize a user-supplied MP3 filename.""" if not isinstance(filename, str): return None filename = filename.strip() if not filename: return None if Path(filename).name != filename: return None if filename.lower().endswith(".mp3"): filename = filename[:-4] if not filename or filename in {".", ".."}: return None if any(ch in filename for ch in '/\\\x00'): return None return filename + ".mp3" def directory_for_kind(kind): if kind == "recording": return RECORDINGS_DIR if kind == "finished": return FINISHED_DIR return None def path_for_kind(kind, filename): filename = safe_filename(filename) directory = directory_for_kind(kind) if filename is None or directory is None: return None return directory / filename def rename_file(kind, old_filename, new_filename): old_path = path_for_kind(kind, old_filename) new_filename = clean_new_filename(new_filename) if old_path is None or new_filename is None or not old_path.is_file(): raise ValueError("Ungültiger Dateiname") new_path = old_path.with_name(new_filename) if new_path.exists(): raise ValueError("Eine Datei mit diesem Namen existiert bereits") old_waveform = waveform_path(old_path.name) if kind == "recording" else None new_waveform = waveform_path(new_path.name) if kind == "recording" else None old_path.rename(new_path) if old_waveform and old_waveform.is_file(): try: old_waveform.rename(new_waveform) except OSError: # The audio file was renamed successfully; an old waveform cache # is harmless and will be regenerated under the new name. pass return new_path def write_metadata(path, metadata): """Rewrite only the MP3 metadata without re-encoding the audio.""" temp = path.with_name(f".{path.name}.metadata.mp3") values = { "date": str(metadata.get("date", "")).strip(), "artist": str(metadata.get("artist", "")).strip(), "title": str(metadata.get("title", "")).strip(), "album": str(metadata.get("album", "")).strip(), } cmd = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(path), "-map", "0", "-c", "copy", "-map_metadata", "-1", ] for key, value in values.items(): cmd += ["-metadata", f"{key}={value}"] cmd.append(str(temp)) try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode != 0 or not temp.is_file() or temp.stat().st_size == 0: temp.unlink(missing_ok=True) raise ValueError("ID3-Tags konnten nicht gespeichert werden") temp.replace(path) except subprocess.TimeoutExpired: temp.unlink(missing_ok=True) raise ValueError("Speichern der ID3-Tags hat zu lange gedauert") except OSError as exc: temp.unlink(missing_ok=True) raise ValueError(f"Speichern der ID3-Tags fehlgeschlagen: {exc}") def update_finished_file(old_filename, new_filename, metadata): """Update metadata and optionally rename a finished file in one operation.""" old_path = finished_path(old_filename) new_filename = clean_new_filename(new_filename) if old_path is None or not old_path.is_file(): raise ValueError("Datei nicht gefunden") if new_filename is None: raise ValueError("Ungültiger Dateiname") new_path = FINISHED_DIR / new_filename if new_path != old_path and new_path.exists(): raise ValueError("Eine Datei mit diesem Namen existiert bereits") date = str(metadata.get("date", "")).strip() if not date: raise ValueError("Zeitpunkt fehlt") try: dt = datetime.fromisoformat(date) if dt.tzinfo is None: dt = dt.replace(tzinfo=ZoneInfo("Europe/Berlin")) date = dt.isoformat() except ValueError: raise ValueError("Ungültiger Zeitpunkt") values = { "date": date, "artist": str(metadata.get("artist", "")).strip(), "title": str(metadata.get("title", "")).strip(), "album": str(metadata.get("album", "")).strip(), } # Build the complete new MP3 first. The original remains untouched until # FFmpeg has successfully written the new file. temp = FINISHED_DIR / f".{new_path.name}.update.tmp.mp3" temp.unlink(missing_ok=True) cmd = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(old_path), "-map", "0", "-c", "copy", "-map_metadata", "-1", ] for key, value in values.items(): cmd += ["-metadata", f"{key}={value}"] cmd.append(str(temp)) try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode != 0 or not temp.is_file() or temp.stat().st_size == 0: raise ValueError("ID3-Tags konnten nicht gespeichert werden") temp.replace(new_path) if new_path != old_path: old_path.unlink() return new_path except subprocess.TimeoutExpired: temp.unlink(missing_ok=True) raise ValueError("Speichern der Datei hat zu lange gedauert") except OSError as exc: temp.unlink(missing_ok=True) raise ValueError(f"Datei konnte nicht gespeichert werden: {exc}") except ValueError: temp.unlink(missing_ok=True) raise def delete_file(kind, filename): path = path_for_kind(kind, filename) if path is None or not path.is_file(): raise ValueError("Datei nicht gefunden") path.unlink() if kind == "recording": try: waveform_path(path.name).unlink(missing_ok=True) except OSError: pass def source_path(filename): filename = safe_filename(filename) if filename is None: return None path = RECORDINGS_DIR / filename return path if path.is_file() else None def finished_path(filename): filename = safe_filename(filename) if filename is None: return None return FINISHED_DIR / filename def get_duration(path): try: result = subprocess.run( [ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(path), ], capture_output=True, text=True, timeout=10, ) if result.returncode != 0: return None return float(result.stdout.strip()) except (OSError, ValueError, subprocess.SubprocessError): return None def waveform_path(filename): return WAVEFORMS_DIR / f"{filename}.png" def create_waveform(filename): source = source_path(filename) if source is None: return None output = waveform_path(filename) WAVEFORMS_DIR.mkdir(parents=True, exist_ok=True) try: if output.is_file() and output.stat().st_mtime >= source.stat().st_mtime: return output except FileNotFoundError: pass temp = output.with_suffix(".tmp.png") try: result = subprocess.run( [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(source), "-filter_complex", "aformat=channel_layouts=mono,showwavespic=s=2400x240:split_channels=0", "-frames:v", "1", str(temp), ], capture_output=True, text=True, timeout=120, ) if result.returncode != 0 or not temp.is_file(): print(f"[waveform] failed for {filename}: {result.stderr.strip()}", flush=True) return None temp.replace(output) return output except Exception as exc: print(f"[waveform] failed for {filename}: {exc}", flush=True) try: temp.unlink(missing_ok=True) except OSError: pass return None def read_metadata(path): """Read common ID3/FFmpeg metadata from an MP3.""" try: result = subprocess.run( ["ffprobe", "-v", "error", "-show_entries", "format_tags=date,artist,title,album", "-of", "json", str(path)], capture_output=True, text=True, timeout=10, ) if result.returncode != 0: return {} data = json.loads(result.stdout) tags = data.get("format", {}).get("tags", {}) or {} return {str(k).lower(): str(v) for k, v in tags.items() if v is not None} except Exception: return {} def metadata_datetime(path): tags = read_metadata(path) value = tags.get("date", "") if value: try: dt = datetime.fromisoformat(value.replace("Z", "+00:00")) return dt.astimezone(ZoneInfo("Europe/Berlin")).strftime("%d.%m.%Y %H:%M:%S") except ValueError: pass return format_datetime(path.stat().st_mtime) def metadata_timestamp(path, tags=None): tags = tags if tags is not None else read_metadata(path) value = tags.get("date", "") if value: try: dt = datetime.fromisoformat(value.replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=ZoneInfo("Europe/Berlin")) return dt.astimezone(ZoneInfo("Europe/Berlin")).timestamp() except ValueError: pass return path.stat().st_mtime def metadata_form_value(path): tags = read_metadata(path) value = tags.get("date", "") if value: try: dt = datetime.fromisoformat(value.replace("Z", "+00:00")) return dt.astimezone(ZoneInfo("Europe/Berlin")).strftime("%Y-%m-%dT%H:%M") except ValueError: pass return datetime.fromtimestamp(path.stat().st_mtime, ZoneInfo("Europe/Berlin")).strftime("%Y-%m-%dT%H:%M") def create_finished(filename, start, end, metadata=None, progress_callback=None): source = source_path(filename) if source is None: raise ValueError("Aufnahme nicht gefunden") try: start = float(start) end = float(end) except (TypeError, ValueError): raise ValueError("Ungültige Zeitangabe") duration = get_duration(source) if duration is None: raise ValueError("Aufnahmedauer konnte nicht ermittelt werden") if start < 0 or end > duration or end <= start: raise ValueError("Ungültiger Auswahlbereich") if end - start < 0.5: raise ValueError("Der ausgewählte Bereich ist zu kurz") FINISHED_DIR.mkdir(parents=True, exist_ok=True) destination = finished_path(filename) temp = FINISHED_DIR / f".{filename}.tmp.mp3" selected_duration = end - start metadata = metadata or {} cmd = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-fflags", "+genpts", "-ss", f"{start:.3f}", "-i", str(source), "-t", f"{selected_duration:.3f}", "-map", "0:a:0", "-vn", "-sn", "-dn", *( ["-af", f"loudnorm=I={NORMALIZE_TARGET}:TP={NORMALIZE_TRUE_PEAK}:LRA={NORMALIZE_LRA}"] if NORMALIZE else [] ), "-c:a", "libmp3lame", "-b:a", "128k", "-avoid_negative_ts", "make_zero", "-metadata", f"date={metadata.get('date', '')}", "-metadata", f"artist={metadata.get('artist', '')}", "-metadata", f"title={metadata.get('title', '')}", "-metadata", f"album={metadata.get('album', '')}", "-progress", "pipe:1", "-nostats", str(temp), ] try: process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1 ) stderr_lines = [] stderr_thread = threading.Thread( target=lambda: stderr_lines.extend(process.stderr.readlines()), daemon=True, ) stderr_thread.start() last_progress = -1 while True: line = process.stdout.readline() if not line: if process.poll() is not None: break time.sleep(0.05) continue line = line.strip() if line.startswith("out_time_us="): try: out_us = int(line.split("=", 1)[1]) progress = max(0.0, min(100.0, out_us / 1_000_000 / selected_duration * 100.0)) if progress_callback and (progress - last_progress >= 0.2 or progress >= 100): progress_callback(progress) last_progress = progress except (ValueError, ZeroDivisionError): pass returncode = process.wait() stderr_thread.join(timeout=2) stderr = "".join(stderr_lines).strip() if returncode != 0 or not temp.is_file(): try: temp.unlink(missing_ok=True) except OSError: pass print(f"[edit] ffmpeg failed for {filename}: {stderr}", flush=True) raise RuntimeError("Schneiden fehlgeschlagen" + (f": {stderr[-500]}" if stderr else "")) temp.replace(destination) if progress_callback: progress_callback(100.0) return destination, get_duration(destination) except Exception: try: temp.unlink(missing_ok=True) except OSError: pass raise def run_edit_job(job_id, filename, start, end, metadata): def update(**values): with JOBS_LOCK: job = JOBS.get(job_id) if job: job.update(values) update(status="running", progress=0.0) try: destination, duration = create_finished( filename, start, end, metadata=metadata, progress_callback=lambda value: update(progress=value), ) update(status="done", progress=100.0, filename=destination.name, duration=duration, finished_at=time.time()) except Exception as exc: print(f"[edit] job {job_id} failed for {filename}: {exc}", flush=True) update(status="error", error=str(exc), finished_at=time.time()) # --------------------------------------------------------------------------- # Browser update events # --------------------------------------------------------------------------- def broadcast_event(event, data=None): payload = json.dumps({"event": event, **(data or {})}, ensure_ascii=False) dead = [] with SSE_LOCK: for queue in list(SSE_CLIENTS): try: queue.put_nowait(payload) except Exception: dead.append(queue) for queue in dead: SSE_CLIENTS.discard(queue) def sse_stream(): import queue client_queue = queue.Queue() with SSE_LOCK: SSE_CLIENTS.add(client_queue) try: yield ": connected\n\n" while True: try: payload = client_queue.get(timeout=20) yield f"data: {payload}\n\n" except queue.Empty: yield ": keepalive\n\n" finally: with SSE_LOCK: SSE_CLIENTS.discard(client_queue) # --------------------------------------------------------------------------- # Persistent file index # --------------------------------------------------------------------------- INDEX_LOCK = threading.RLock() FILE_INDEX = {"recordings": {}, "finished": {}} def _index_key(path): return path.name def _index_section(kind): return FILE_INDEX["recordings" if kind == "recording" else "finished"] def _entry_from_path(path, kind, analyze=False, existing=None): """Create/update one index entry. FFprobe is only used when analyze=True.""" try: stat = path.stat() except OSError: return None entry = dict(existing or {}) entry.update({ "filename": path.name, "size": stat.st_size, "mtime": stat.st_mtime, "kind": kind, }) if analyze: if kind == "finished": tags = read_metadata(path) entry.update({ "date": tags.get("date", ""), "artist": tags.get("artist", ""), "title": tags.get("title", ""), "album": tags.get("album", ""), }) entry["sort_ts"] = metadata_timestamp(path, tags) else: entry.update({ "date": "", "artist": "", "title": "", "album": "", }) entry["duration"] = get_duration(path) entry["analyzed_mtime"] = stat.st_mtime entry["analyzed_size"] = stat.st_size return entry def _write_index_locked(): """Atomically persist the in-memory index.""" INDEX_FILE.parent.mkdir(parents=True, exist_ok=True) temp = INDEX_FILE.with_name(f".{INDEX_FILE.name}.{uuid.uuid4().hex}.tmp") temp.write_text(json.dumps(FILE_INDEX, ensure_ascii=False, indent=2), encoding="utf-8") temp.replace(INDEX_FILE) def load_file_index(): """Load the index and reconcile it with the filesystem. Reconciliation only stats existing files. FFprobe is run for a file when there is no cached entry or its size/mtime changed since the last analysis. """ global FILE_INDEX with INDEX_LOCK: try: data = json.loads(INDEX_FILE.read_text(encoding="utf-8")) if not isinstance(data, dict): raise ValueError("index root is not an object") FILE_INDEX = { "recordings": data.get("recordings", {}) if isinstance(data.get("recordings", {}), dict) else {}, "finished": data.get("finished", {}) if isinstance(data.get("finished", {}), dict) else {}, } except (OSError, ValueError, json.JSONDecodeError): FILE_INDEX = {"recordings": {}, "finished": {}} changed = False for kind, directory in (("recording", RECORDINGS_DIR), ("finished", FINISHED_DIR)): section = _index_section(kind) existing_names = set(section) current_names = set() for path in directory.glob("*.mp3"): current_names.add(path.name) try: stat = path.stat() except OSError: continue entry = section.get(path.name) needs_analysis = ( not isinstance(entry, dict) or entry.get("analyzed_mtime") != stat.st_mtime or entry.get("analyzed_size") != stat.st_size or "duration" not in entry ) new_entry = _entry_from_path(path, kind, analyze=needs_analysis, existing=entry) if new_entry is not None and new_entry != entry: section[path.name] = new_entry changed = True for name in existing_names - current_names: section.pop(name, None) changed = True if changed or not INDEX_FILE.exists(): _write_index_locked() print( f"[index] loaded {len(FILE_INDEX['recordings'])} recordings, " f"{len(FILE_INDEX['finished'])} finished files", flush=True, ) def index_file_created(path, kind): """Add a newly created MP3 to the index. Finished files are already stable when created atomically (uploads/finished outputs), while recordings may still be written and are analyzed only on close_write. """ with INDEX_LOCK: entry = _entry_from_path(path, kind, analyze=(kind == "finished")) if entry is None: return entry.setdefault("duration", None) entry.setdefault("date", "") entry.setdefault("artist", "") entry.setdefault("title", "") entry.setdefault("album", "") _index_section(kind)[path.name] = entry _write_index_locked() broadcast_event("files_changed", {"action": "created", "filename": path.name}) def index_file_closed(path, kind): """Analyze a file once it is closed and therefore stable.""" with INDEX_LOCK: if not path.is_file(): return entry = _entry_from_path(path, kind, analyze=True, existing=_index_section(kind).get(path.name)) if entry is None: return _index_section(kind)[path.name] = entry _write_index_locked() broadcast_event("files_changed", {"action": "closed", "filename": path.name}) def index_file_deleted(path, kind): with INDEX_LOCK: _index_section(kind).pop(path.name, None) _write_index_locked() broadcast_event("files_changed", {"action": "deleted", "filename": path.name}) def index_file_moved(src_path, dest_path, kind): with INDEX_LOCK: section = _index_section(kind) entry = section.pop(src_path.name, None) if dest_path.is_file(): entry = _entry_from_path(dest_path, kind, analyze=entry is None, existing=entry) if entry is not None: section[dest_path.name] = entry _write_index_locked() broadcast_event("files_changed", {"action": "moved", "filename": dest_path.name}) class RecordingFileEventHandler(FileSystemEventHandler): """Keep the persistent file index in sync and notify connected browsers.""" def _kind_for_path(self, path): try: resolved = Path(path).resolve() if resolved.parent == RECORDINGS_DIR.resolve(): return "recording" if resolved.parent == FINISHED_DIR.resolve(): return "finished" except OSError: pass return None def _is_relevant(self, path): path = Path(path) return not path.name.startswith(".") and path.suffix.lower() == ".mp3" def on_created(self, event): if event.is_directory or not self._is_relevant(event.src_path): return kind = self._kind_for_path(event.src_path) if kind: index_file_created(Path(event.src_path), kind) def on_deleted(self, event): if event.is_directory or not self._is_relevant(event.src_path): return kind = self._kind_for_path(event.src_path) if kind: index_file_deleted(Path(event.src_path), kind) def on_moved(self, event): if event.is_directory: return src = Path(event.src_path) dest = Path(event.dest_path) if not self._is_relevant(src) and not self._is_relevant(dest): return kind = self._kind_for_path(dest) or self._kind_for_path(src) if kind: index_file_moved(src, dest, kind) def on_closed(self, event): # Linux/inotify emits close_write when FFmpeg has finished writing. if event.is_directory or not self._is_relevant(event.src_path): return kind = self._kind_for_path(event.src_path) if kind: index_file_closed(Path(event.src_path), kind) def start_file_observer(): global FILE_OBSERVER RECORDINGS_DIR.mkdir(parents=True, exist_ok=True) FINISHED_DIR.mkdir(parents=True, exist_ok=True) load_file_index() observer = Observer() handler = RecordingFileEventHandler() observer.schedule(handler, str(RECORDINGS_DIR), recursive=False) observer.schedule(handler, str(FINISHED_DIR), recursive=False) observer.start() FILE_OBSERVER = observer print("[watcher] Monitoring recordings and finished directories", flush=True) # --------------------------------------------------------------------------- # HTML # --------------------------------------------------------------------------- EDITOR_HTML = """ {{ page_title }} {% if favicon_url %}{% endif %}
← Zurück zu den Aufnahmen {% if logo_url %}{% endif %}

{{ edit_page_heading }}

{{ site_name }} · {{ filename }} · Originaldauer {{ duration }}
Wellenform
00:00:00 {{ duration }}
Zoom: 100 % 00:00:00
Anfang00:00:00
Ende00:00:00
Neue Dauer00:00:00
Original herunterladen
Beim Erstellen einer fertigen Aufnahme bleibt das Original unverändert.

Metadaten der fertigen Aufnahme

Verarbeitung läuft …0 %
""" INDEX_HTML = """ {{ page_title }} {% if favicon_url %}{% endif %}
Fertige Aufnahmen aus {{ recordings_dir }}

Aufnahmen bearbeiten

MP3-Datei vom PC auswählen
{% if recordings %} {% for item in recordings %} {% endfor %}
DateinameGrößeDauer
{{ item.filename }} {{ item.size }} {{ item.duration }} ✂ Bearbeiten
{% else %}
Keine unbearbeiteten Aufnahmen vorhanden.
{% endif %}

Bearbeitete Aufnahmen

{% if finished %} {% for item in finished %} {% endfor %}
ZeitpunktRednerBeschreibungVeranstaltungDauer
{{ item.datetime }} {{ item.artist or "–" }} {{ item.title or "–" }} {{ item.album or "–" }} {{ item.duration }} ▶ Abspielen / Download
{% else %}
Noch keine bearbeiteten Aufnahmen vorhanden.
{% endif %}

Bearbeitete Aufnahme ändern

""" # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @app.route("/branding/logo") def branding_logo(): if LOGO_FILE is None or not LOGO_FILE.is_file(): abort(404) return send_file(LOGO_FILE) @app.route("/branding/favicon") def branding_favicon(): if FAVICON_FILE is None or not FAVICON_FILE.is_file(): abort(404) return send_file(FAVICON_FILE) @app.route("/") def index(): with INDEX_LOCK: recording_entries = [dict(entry) for entry in FILE_INDEX["recordings"].values()] finished_entries = [dict(entry) for entry in FILE_INDEX["finished"].values()] recordings = [] for item in recording_entries: recordings.append({ "filename": item["filename"], "datetime": format_datetime(item.get("mtime", time.time())), "size": format_size(item.get("size", 0)), "duration": format_duration(item.get("duration")), "mtime": item.get("mtime", 0), }) finished = [] for item in finished_entries: date = item.get("date", "") sort_ts = item.get("sort_ts") if sort_ts is None: sort_ts = item.get("mtime", 0) dt_text = format_datetime(sort_ts) if date: try: dt = datetime.fromisoformat(date.replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=ZoneInfo("Europe/Berlin")) dt_text = dt.astimezone(ZoneInfo("Europe/Berlin")).strftime("%d.%m.%Y %H:%M:%S") except ValueError: pass finished.append({ "filename": item["filename"], "datetime": dt_text, "artist": item.get("artist", ""), "title": item.get("title", ""), "album": item.get("album", ""), "size": format_size(item.get("size", 0)), "duration": format_duration(item.get("duration")), "sort_date": date or datetime.fromtimestamp(item.get("mtime", 0), ZoneInfo("Europe/Berlin")).isoformat(), "sort_ts": sort_ts, }) recordings.sort(key=lambda x: x["mtime"], reverse=True) finished.sort(key=lambda x: x["sort_date"], reverse=True) artists = sorted({x["artist"] for x in finished if x["artist"]}, key=str.casefold) albums = sorted({x["album"] for x in finished if x["album"]}, key=str.casefold) return render_template_string( INDEX_HTML, recordings=recordings, finished=finished, artists=artists, albums=albums, recordings_dir=str(RECORDINGS_DIR), **branding_context(), ) @app.route("/api/events") def api_events(): return Response( stream_with_context(sse_stream()), mimetype="text/event-stream", headers={ "Cache-Control": "no-cache, no-store, must-revalidate", "X-Accel-Buffering": "no", "Connection": "keep-alive", }, ) @app.route("/edit/") def edit_recording(filename): path = source_path(filename) if path is None: abort(404) duration = get_duration(path) if duration is None: abort(500, description="Aufnahmedauer konnte nicht ermittelt werden") waveform = create_waveform(filename) if waveform is None: abort(500, description="Wellenform konnte nicht erzeugt werden") return render_template_string( EDITOR_HTML, filename=filename, duration=format_duration(duration), duration_seconds=duration, metadata=read_metadata(path), metadata_datetime=metadata_form_value(path), waveform_url=url_for("waveform", filename=filename), audio_url=url_for("recording", filename=filename), **branding_context(), ) @app.route("/waveforms/") def waveform(filename): if source_path(filename) is None: abort(404) output = create_waveform(filename) if output is None: abort(404) return send_from_directory(WAVEFORMS_DIR, output.name, mimetype="image/png") @app.route("/recordings/") def recording(filename): path = source_path(filename) if path is None: abort(404) return send_from_directory(RECORDINGS_DIR, path.name, mimetype="audio/mpeg") @app.route("/finished/") def finished_file(filename): path = finished_path(filename) if path is None or not path.is_file(): abort(404) return send_from_directory(FINISHED_DIR, path.name, mimetype="audio/mpeg", as_attachment=False) @app.route("/api/upload", methods=["POST"]) def api_upload(): upload = request.files.get("file") if upload is None or not upload.filename: return jsonify({"success": False, "error": "Keine Datei ausgewählt"}), 400 original_name = Path(upload.filename).name if original_name != upload.filename: return jsonify({"success": False, "error": "Ungültiger Dateiname"}), 400 if not original_name.lower().endswith(".mp3"): return jsonify({"success": False, "error": "Bitte eine MP3-Datei auswählen"}), 400 filename = original_name target = RECORDINGS_DIR / filename if target.exists(): return jsonify({"success": False, "error": f"Eine Datei mit dem Namen '{filename}' existiert bereits"}), 409 temp = RECORDINGS_DIR / f".{filename}.{uuid.uuid4().hex}.uploading" try: RECORDINGS_DIR.mkdir(parents=True, exist_ok=True) upload.save(temp) os.replace(temp, target) print(f"[upload] Uploaded recording: {filename}", flush=True) return jsonify({"success": True, "filename": filename}) except OSError as exc: try: temp.unlink(missing_ok=True) except OSError: pass print(f"[upload] upload failed for {filename}: {exc}", flush=True) return jsonify({"success": False, "error": "Upload konnte nicht gespeichert werden"}), 500 @app.route("/api/file", methods=["POST"]) def api_file(): data = request.get_json(silent=True) or {} action = data.get("action") kind = data.get("kind") filename = data.get("filename") if kind not in {"recording", "finished"}: return jsonify({"success": False, "error": "Ungültiger Dateityp"}), 400 try: if action == "rename": path = rename_file(kind, filename, data.get("new_filename")) return jsonify({"success": True, "filename": path.name}) if action == "update_finished": if kind != "finished": return jsonify({"success": False, "error": "Metadaten können nur bei bearbeiteten Aufnahmen geändert werden"}), 400 metadata = data.get("metadata") or {} path = update_finished_file(filename, data.get("new_filename"), metadata) return jsonify({"success": True, "filename": path.name}) if action == "update_metadata": if kind != "finished": return jsonify({"success": False, "error": "Metadaten können nur bei bearbeiteten Aufnahmen geändert werden"}), 400 path = finished_path(filename) if path is None or not path.is_file(): raise ValueError("Datei nicht gefunden") metadata = data.get("metadata") or {} date = str(metadata.get("date", "")).strip() if not date: raise ValueError("Zeitpunkt fehlt") try: dt = datetime.fromisoformat(date) if dt.tzinfo is None: dt = dt.replace(tzinfo=ZoneInfo("Europe/Berlin")) date = dt.isoformat() except ValueError: raise ValueError("Ungültiger Zeitpunkt") write_metadata(path, { "date": date, "artist": str(metadata.get("artist", "")).strip(), "title": str(metadata.get("title", "")).strip(), "album": str(metadata.get("album", "")).strip(), }) return jsonify({"success": True}) if action == "delete": delete_file(kind, filename) return jsonify({"success": True}) return jsonify({"success": False, "error": "Ungültige Aktion"}), 400 except ValueError as exc: return jsonify({"success": False, "error": str(exc)}), 400 except OSError as exc: print(f"[file] operation failed for {filename}: {exc}", flush=True) return jsonify({"success": False, "error": "Dateioperation fehlgeschlagen"}), 500 @app.route("/api/edit", methods=["POST"]) def api_edit(): data = request.get_json(silent=True) or {} filename = data.get("filename") start = data.get("start") end = data.get("end") metadata = data.get("metadata") or {} source = source_path(filename) if source is None: return jsonify({"success": False, "error": "Aufnahme nicht gefunden"}), 404 try: start_f = float(start) end_f = float(end) except (TypeError, ValueError): return jsonify({"success": False, "error": "Ungültige Zeitangabe"}), 400 if end_f <= start_f or end_f - start_f < 0.5: return jsonify({"success": False, "error": "Ungültiger Auswahlbereich"}), 400 job_id = uuid.uuid4().hex with JOBS_LOCK: JOBS[job_id] = {"status":"queued", "progress":0.0, "filename":filename, "created_at":time.time()} metadata = { "date": str(metadata.get("date", "")).strip(), "artist": str(metadata.get("artist", "")).strip(), "title": str(metadata.get("title", "")).strip(), "album": str(metadata.get("album", "")).strip(), } if not metadata["date"]: return jsonify({"success": False, "error": "Zeitpunkt fehlt"}), 400 try: dt = datetime.fromisoformat(metadata["date"]) if dt.tzinfo is None: dt = dt.replace(tzinfo=ZoneInfo("Europe/Berlin")) metadata["date"] = dt.isoformat() except ValueError: return jsonify({"success": False, "error": "Ungültiger Zeitpunkt"}), 400 threading.Thread(target=run_edit_job, args=(job_id, filename, start_f, end_f, metadata), daemon=True).start() return jsonify({"success": True, "job_id": job_id}), 202 @app.route("/api/edit/status/") def api_edit_status(job_id): with JOBS_LOCK: job = JOBS.get(job_id) if not job: return jsonify({"success": False, "error": "Job nicht gefunden"}), 404 result = dict(job) return jsonify({"success": True, **result}) RECORDINGS_DIR.mkdir(parents=True, exist_ok=True) FINISHED_DIR.mkdir(parents=True, exist_ok=True) start_file_observer() if __name__ == "__main__": app.run(host="0.0.0.0", port=WEB_PORT)