Files
event-audio-recorder/editor.py
T
2026-09-29 22:47:11 +02:00

1710 lines
67 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import subprocess
import threading
import time
import uuid
import json
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
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"))
WAVEFORMS_DIR = Path(os.environ.get("WAVEFORMS_DIR", "/waveforms"))
WEB_PORT = int(os.environ.get("WEB_PORT", "8080"))
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)
class RecordingFileEventHandler(FileSystemEventHandler):
"""Turn recording/finished filesystem changes into browser events.
Only meaningful MP3 file lifecycle events are forwarded. In particular,
continuous writes to an active recording are deliberately ignored so the
browser is not flooded while FFmpeg is recording.
"""
def _is_relevant(self, path):
path = Path(path)
if path.name.startswith("."):
return False
return path.suffix.lower() == ".mp3"
def on_created(self, event):
if event.is_directory or not self._is_relevant(event.src_path):
return
broadcast_event("files_changed", {"action": "created", "filename": Path(event.src_path).name})
def on_deleted(self, event):
if event.is_directory or not self._is_relevant(event.src_path):
return
broadcast_event("files_changed", {"action": "deleted", "filename": Path(event.src_path).name})
def on_moved(self, event):
if event.is_directory:
return
if self._is_relevant(event.src_path) or self._is_relevant(event.dest_path):
broadcast_event("files_changed", {"action": "moved", "filename": Path(event.dest_path).name})
def on_closed(self, event):
# watchdog's Linux/inotify backend emits this when FFmpeg closes the
# recording. That is the useful "recording finished" signal.
if event.is_directory or not self._is_relevant(event.src_path):
return
broadcast_event("files_changed", {"action": "closed", "filename": Path(event.src_path).name})
def start_file_observer():
global FILE_OBSERVER
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
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 = """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Audio – Aufnahme bearbeiten</title>
<style>
* { box-sizing: border-box; }
body { margin:0; padding:24px; background:#111; color:#eee; font-family:Arial,Helvetica,sans-serif; }
.container { max-width:1400px; margin:0 auto; }
h1 { margin:0 0 6px; font-size:28px; }
.subtitle { color:#aaa; margin-bottom:22px; }
.editor { background:#181818; border:1px solid #333; border-radius:10px; padding:20px; }
.wave-wrap { position:relative; width:100%; height:240px; background:#0b0b0b; border-radius:6px; overflow:hidden; user-select:none; touch-action:none; }
.wave-image { position:absolute; top:0; left:0; width:100%; height:100%; object-fit:fill; display:block; opacity:.9; pointer-events:none; }
.selection { position:absolute; top:0; bottom:0; background:rgba(80,150,255,.16); border-left:2px solid #fff; border-right:2px solid #fff; pointer-events:none; }
.playhead { position:absolute; top:0; bottom:0; width:2px; margin-left:-1px; background:#ffcc33; box-shadow:0 0 5px #000; z-index:4; pointer-events:none; }
.handle { position:absolute; top:0; bottom:0; width:18px; margin-left:-9px; cursor:ew-resize; z-index:3; touch-action:none; }
.handle::after { content:""; position:absolute; left:7px; top:0; bottom:0; width:4px; background:#fff; border-radius:2px; box-shadow:0 0 5px #000; }
.view-scroll { position:relative; width:100%; height:14px; margin-top:5px; background:#292929; border-radius:7px; overflow:hidden; touch-action:none; cursor:grab; }
.view-scroll-thumb { position:absolute; top:2px; bottom:2px; left:0; min-width:14px; background:#777; border-radius:5px; cursor:grab; touch-action:none; }
.view-scroll-thumb:hover { background:#999; }
.view-scroll-thumb.dragging { background:#aaa; cursor:grabbing; }
.time-row { display:flex; justify-content:space-between; margin-top:8px; color:#aaa; font-size:14px; }
.controls { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-top:18px; }
button, a.button { border:0; border-radius:6px; padding:11px 16px; background:#333; color:#fff; cursor:pointer; text-decoration:none; font-size:15px; }
button:hover, a.button:hover { background:#444; }
.button.danger { background:#4a2020; }
.button.danger:hover { background:#6a2b2b; }
.actions { display:flex; gap:6px; flex-wrap:wrap; }
button.primary { background:#1769aa; }
button.danger { background:#8b0000; }
button:disabled { opacity:.45; cursor:not-allowed; }
.position { margin-left:auto; color:#ccc; font-variant-numeric:tabular-nums; }
.zoom { color:#aaa; font-variant-numeric:tabular-nums; }
.values { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-top:20px; }
.value { background:#222; border-radius:6px; padding:12px; }
.value span { display:block; color:#999; font-size:13px; margin-bottom:5px; }
.value strong { font-size:20px; font-variant-numeric:tabular-nums; }
.actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:20px; }
.info { margin-top:16px; color:#999; font-size:14px; }
.message { margin-top:15px; padding:10px 12px; border-radius:6px; display:none; }
.message.ok { display:block; background:#173d24; color:#b8f5c9; }
.message.error { display:block; background:#4a1b1b; color:#ffd0d0; }
.meta-dialog { position:fixed; inset:0; background:rgba(0,0,0,.78); display:none; align-items:center; justify-content:center; z-index:900; padding:20px; }
.meta-dialog.active { display:flex; }
.meta-card { width:min(560px,100%); background:#202020; border:1px solid #444; border-radius:10px; padding:22px; box-shadow:0 10px 40px #000; }
.meta-card h2 { margin-top:0; }
.meta-card label { display:block; margin:14px 0; color:#ccc; }
.meta-card input { display:block; width:100%; margin-top:6px; padding:10px; background:#111; color:#eee; border:1px solid #555; border-radius:5px; font-size:16px; }
.meta-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:20px; }
.meta-actions button { margin:0; }
.progress-box { display:none; margin-top:16px; }
.progress-box.active { display:block; }
.progress-track { height:18px; background:#292929; border-radius:9px; overflow:hidden; }
.progress-bar { height:100%; width:0; background:#1769aa; transition:width .2s ease; }
.progress-info { display:flex; justify-content:space-between; margin-top:7px; color:#aaa; font-size:14px; font-variant-numeric:tabular-nums; }
.back { display:inline-block; margin-bottom:18px; color:#bbb; text-decoration:none; }
@media(max-width:700px) {
body { padding:12px; }
.editor { padding:12px; }
.wave-wrap { height:180px; }
.view-scroll { height:18px; }
.view-scroll-thumb { min-width:28px; }
.zoom { width:100%; margin-left:0; }
.values { grid-template-columns:1fr; }
.position { width:100%; margin-left:0; }
button, a.button { flex:1 1 auto; }
}
</style>
</head>
<body>
<div class="container">
<a class="back" href="/">← Zurück zu den Aufnahmen</a>
<h1>Aufnahme bearbeiten</h1>
<div class="subtitle">{{ filename }} · Originaldauer {{ duration }}</div>
<div class="editor">
<div class="wave-wrap" id="wave">
<img class="wave-image" id="waveImage" src="{{ waveform_url }}" alt="Wellenform">
<div class="selection" id="selection"></div>
<div class="playhead" id="playhead"></div>
<div class="handle" id="startHandle" title="Anfang verschieben"></div>
<div class="handle" id="endHandle" title="Ende verschieben"></div>
</div>
<div class="view-scroll" id="viewScroll" aria-label="Sichtbaren Bereich verschieben">
<div class="view-scroll-thumb" id="viewScrollThumb"></div>
</div>
<div class="time-row">
<span id="viewStart">00:00:00</span>
<span id="viewEnd">{{ duration }}</span>
</div>
<audio id="audio" controls preload="metadata" style="width:100%;margin-top:18px">
<source src="{{ audio_url }}" type="audio/mpeg">
</audio>
<div class="controls">
<button onclick="jump(-10)">⏮ 10 s</button>
<button onclick="jump(-1)">⏪ 1 s</button>
<button onclick="jump(1)">1 s ⏩</button>
<button onclick="jump(10)">10 s ⏭</button>
<button onclick="playSelection()">▶ Auswahl abspielen</button>
<button onclick="setStartAtCurrent()">⏱ Anfang = Position</button>
<button onclick="setEndAtCurrent()">⏱ Ende = Position</button>
<button onclick="resetZoom()">↺ Gesamtansicht</button>
<span class="zoom" id="zoomValue">Zoom: 100 %</span>
<span class="position" id="position">00:00:00</span>
</div>
<div class="values">
<div class="value"><span>Anfang</span><strong id="startValue">00:00:00</strong></div>
<div class="value"><span>Ende</span><strong id="endValue">00:00:00</strong></div>
<div class="value"><span>Neue Dauer</span><strong id="keepValue">00:00:00</strong></div>
</div>
<div class="actions">
<button class="primary" id="trimButton" onclick="trim()">✂ Fertige Aufnahme erstellen</button>
<a class="button" href="{{ audio_url }}" download>Original herunterladen</a>
</div>
<div class="info">Beim Erstellen einer fertigen Aufnahme bleibt das Original unverändert.</div>
<div class="meta-dialog" id="metaDialog">
<div class="meta-card">
<h2>Metadaten der fertigen Aufnahme</h2>
<label>Zeitpunkt<input id="metaDate" type="datetime-local" value="{{ metadata_datetime }}" required></label>
<label>Redner<input id="metaArtist" type="text" value="{{ metadata.artist|default('', true) }}" placeholder="Name des Redners"></label>
<label>Beschreibung<input id="metaTitle" type="text" value="{{ metadata.title|default('', true) }}" placeholder="Titel / Beschreibung des Vortrags"></label>
<label>Veranstaltung<input id="metaAlbum" type="text" value="{{ metadata.album|default('', true) }}" placeholder="Veranstaltung"></label>
<div class="meta-actions"><button onclick="closeMetaDialog()">Abbrechen</button><button class="primary" onclick="submitWithMetadata()">Fertige Aufnahme erstellen</button></div>
</div>
</div>
<div class="progress-box" id="progressBox"><div class="progress-track"><div class="progress-bar" id="progressBar"></div></div><div class="progress-info"><span id="progressText">Verarbeitung läuft …</span><span id="progressPercent">0 %</span></div></div>
<div id="message" class="message"></div>
</div>
</div>
<script>
const duration = {{ duration_seconds|tojson }};
const filename = {{ filename|tojson }};
let start = 0;
let end = duration;
let dragging = null;
let zoom = 1;
let viewStart = 0;
let viewEnd = duration;
const MIN_ZOOM = 1;
const MAX_ZOOM = 16;
const ZOOM_FACTOR = 1.5;
const wave = document.getElementById('wave');
const waveImage = document.getElementById('waveImage');
const startHandle = document.getElementById('startHandle');
const endHandle = document.getElementById('endHandle');
const selection = document.getElementById('selection');
const playhead = document.getElementById('playhead');
const audio = document.getElementById('audio');
const viewScroll = document.getElementById('viewScroll');
const viewScrollThumb = document.getElementById('viewScrollThumb');
let scrollbarDragging = false;
let scrollbarPointerId = null;
let scrollbarStartX = 0;
let scrollbarStartView = 0;
function fmt(sec) {
sec = Math.max(0, Math.round(sec));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
function xToTime(clientX) {
const r = wave.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (clientX-r.left)/r.width));
return viewStart + ratio * (viewEnd-viewStart);
}
function timeToPercent(t) {
const span = viewEnd-viewStart;
if (span <= 0) return 0;
return ((t-viewStart)/span)*100;
}
function render() {
const a = timeToPercent(start);
const b = timeToPercent(end);
startHandle.style.left = a+'%';
endHandle.style.left = b+'%';
selection.style.left = a+'%';
selection.style.width = Math.max(0, b-a)+'%';
const imageWidth = zoom * 100;
waveImage.style.width = imageWidth+'%';
waveImage.style.left = (-(viewStart/duration)*imageWidth)+'%';
document.getElementById('startValue').textContent = fmt(start);
document.getElementById('endValue').textContent = fmt(end);
document.getElementById('keepValue').textContent = fmt(end-start);
document.getElementById('viewStart').textContent = fmt(viewStart);
document.getElementById('viewEnd').textContent = fmt(viewEnd);
document.getElementById('zoomValue').textContent = 'Zoom: '+Math.round(zoom*100)+' %';
// The scrollbar thumb represents the currently visible part of the full recording.
const visiblePercent = Math.max(0, Math.min(100, ((viewEnd-viewStart)/duration)*100));
const offsetPercent = Math.max(0, Math.min(100-visiblePercent, (viewStart/duration)*100));
viewScrollThumb.style.width = visiblePercent+'%';
viewScrollThumb.style.left = offsetPercent+'%';
viewScrollThumb.style.pointerEvents = zoom > 1 ? 'auto' : 'none';
viewScrollThumb.classList.toggle('dragging', scrollbarDragging);
renderPlayhead();
}
function renderPlayhead() {
const t = Math.max(0, Math.min(duration, audio.currentTime || 0));
playhead.style.left = timeToPercent(t)+'%';
}
function setZoom(newZoom, clientX) {
newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));
if (newZoom === zoom) return;
const r = wave.getBoundingClientRect();
let focusRatio = 0.5;
if (typeof clientX === 'number' && r.width > 0) {
focusRatio = Math.max(0, Math.min(1, (clientX-r.left)/r.width));
}
const oldSpan = viewEnd-viewStart;
const focusTime = viewStart + focusRatio*oldSpan;
const newSpan = duration/newZoom;
let newStart = focusTime-focusRatio*newSpan;
newStart = Math.max(0, Math.min(duration-newSpan, newStart));
zoom = newZoom;
viewStart = newStart;
viewEnd = newStart+newSpan;
render();
}
function resetZoom() {
zoom = 1;
viewStart = 0;
viewEnd = duration;
render();
}
function moveViewTo(newStart) {
const span = viewEnd-viewStart;
if (span >= duration) {
viewStart = 0;
viewEnd = duration;
} else {
viewStart = Math.max(0, Math.min(duration-span, newStart));
viewEnd = viewStart+span;
}
render();
}
viewScrollThumb.addEventListener('pointerdown', e => {
if (zoom <= 1) return;
e.preventDefault();
e.stopPropagation();
scrollbarDragging = true;
scrollbarPointerId = e.pointerId;
scrollbarStartX = e.clientX;
scrollbarStartView = viewStart;
viewScrollThumb.setPointerCapture(e.pointerId);
render();
});
viewScrollThumb.addEventListener('pointermove', e => {
if (!scrollbarDragging || e.pointerId !== scrollbarPointerId) return;
const trackWidth = viewScroll.clientWidth;
const thumbWidth = viewScrollThumb.offsetWidth;
const movableWidth = Math.max(1, trackWidth-thumbWidth);
const span = viewEnd-viewStart;
const deltaPixels = e.clientX-scrollbarStartX;
const deltaTime = (deltaPixels/movableWidth) * (duration-span);
moveViewTo(scrollbarStartView+deltaTime);
e.preventDefault();
});
function endScrollbarDrag(e) {
if (!scrollbarDragging || e.pointerId !== scrollbarPointerId) return;
scrollbarDragging = false;
scrollbarPointerId = null;
render();
}
viewScrollThumb.addEventListener('pointerup', endScrollbarDrag);
viewScrollThumb.addEventListener('pointercancel', endScrollbarDrag);
// Clicking/touching the empty part of the scrollbar moves the visible window
// so that the clicked position becomes its center.
viewScroll.addEventListener('pointerdown', e => {
if (zoom <= 1 || e.target === viewScrollThumb) return;
e.preventDefault();
const r = viewScroll.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX-r.left)/r.width));
const span = viewEnd-viewStart;
moveViewTo(ratio*duration-span/2);
});
wave.addEventListener('wheel', e => {
e.preventDefault();
const factor = e.deltaY < 0 ? ZOOM_FACTOR : 1/ZOOM_FACTOR;
setZoom(zoom*factor, e.clientX);
}, {passive:false});
// Touchscreen: two-finger pinch zoom. The midpoint between the fingers
// is used as the zoom focus, just like the mouse position for the wheel.
const touchPointers = new Map();
let pinchDistance = null;
let pinchZoom = null;
function touchDistance(a, b) {
return Math.hypot(a.clientX-b.clientX, a.clientY-b.clientY);
}
function touchMidpoint(a, b) {
return (a.clientX+b.clientX)/2;
}
function startPinch() {
const points = [...touchPointers.values()];
if (points.length !== 2) return;
pinchDistance = touchDistance(points[0], points[1]);
pinchZoom = zoom;
}
wave.addEventListener('pointerdown', e => {
if (e.pointerType !== 'touch') return;
touchPointers.set(e.pointerId, e);
if (touchPointers.size === 2) {
pinchZoom = zoom;
startPinch();
}
}, {passive:false});
wave.addEventListener('pointermove', e => {
if (e.pointerType !== 'touch' || !touchPointers.has(e.pointerId)) return;
touchPointers.set(e.pointerId, e);
if (touchPointers.size !== 2 || !pinchDistance || pinchZoom === null) return;
const points = [...touchPointers.values()];
const distance = touchDistance(points[0], points[1]);
if (distance <= 0) return;
const midpointX = touchMidpoint(points[0], points[1]);
setZoom(pinchZoom * (distance/pinchDistance), midpointX);
e.preventDefault();
}, {passive:false});
function endTouchPointer(e) {
if (e.pointerType !== 'touch') return;
touchPointers.delete(e.pointerId);
if (touchPointers.size < 2) {
pinchDistance = null;
pinchZoom = null;
}
}
wave.addEventListener('pointerup', endTouchPointer);
wave.addEventListener('pointercancel', endTouchPointer);
function beginDrag(which, e) {
e.preventDefault();
dragging = which;
document.body.style.cursor='ew-resize';
}
startHandle.addEventListener('pointerdown', e => beginDrag('start',e));
endHandle.addEventListener('pointerdown', e => beginDrag('end',e));
document.addEventListener('pointermove', e => {
if (!dragging) return;
const t = xToTime(e.clientX);
if (dragging === 'start') start = Math.min(t, end-0.5);
else end = Math.max(t, start+0.5);
render();
});
document.addEventListener('pointerup', () => { dragging=null; document.body.style.cursor=''; });
wave.addEventListener('pointerdown', e => {
if (e.target === startHandle || e.target === endHandle) return;
if (e.pointerType === 'touch' && touchPointers.size >= 2) return;
const t=xToTime(e.clientX);
audio.currentTime = Math.max(0, Math.min(duration,t));
});
audio.addEventListener('timeupdate', () => {
document.getElementById('position').textContent=fmt(audio.currentTime);
renderPlayhead();
if (audio.currentTime >= end && !audio.paused) audio.pause();
});
audio.addEventListener('loadedmetadata', renderPlayhead);
audio.addEventListener('play', renderPlayhead);
audio.addEventListener('pause', renderPlayhead);
audio.addEventListener('seeking', renderPlayhead);
function animatePlayhead() {
renderPlayhead();
if (!audio.paused) requestAnimationFrame(animatePlayhead);
}
audio.addEventListener('play', () => requestAnimationFrame(animatePlayhead));
function jump(sec) { audio.currentTime=Math.max(0,Math.min(duration,audio.currentTime+sec)); renderPlayhead(); }
function playFromStart() { audio.currentTime=start; renderPlayhead(); audio.play(); }
function playSelection() { audio.currentTime=start; renderPlayhead(); audio.play(); }
function setStartAtCurrent() {
const t = Math.max(0, Math.min(duration, audio.currentTime || 0));
if (t >= end - 0.5) {
msg('Der Anfang muss mindestens 0,5 s vor dem Ende liegen.', false);
return;
}
start = t;
render();
}
function setEndAtCurrent() {
const t = Math.max(0, Math.min(duration, audio.currentTime || 0));
if (t <= start + 0.5) {
msg('Das Ende muss mindestens 0,5 s nach dem Anfang liegen.', false);
return;
}
end = t;
render();
}
function msg(text, ok) {
const el=document.getElementById('message'); el.textContent=text; el.className='message '+(ok?'ok':'error');
}
async function trim() {
if (end-start < 0.5) return msg('Der ausgewählte Bereich ist zu kurz.',false);
document.getElementById('metaDialog').classList.add('active');
}
function closeMetaDialog() { document.getElementById('metaDialog').classList.remove('active'); }
async function submitWithMetadata() {
const date=document.getElementById('metaDate').value;
const artist=document.getElementById('metaArtist').value.trim();
const title=document.getElementById('metaTitle').value.trim();
const album=document.getElementById('metaAlbum').value.trim();
if (!date) return alert('Bitte einen Zeitpunkt angeben.');
closeMetaDialog();
const btn=document.getElementById('trimButton');
const progressBox=document.getElementById('progressBox');
const progressBar=document.getElementById('progressBar');
const progressText=document.getElementById('progressText');
const progressPercent=document.getElementById('progressPercent');
btn.disabled=true; audio.pause(); progressBox.classList.add('active'); progressBar.style.width='0%'; progressPercent.textContent='0 %'; progressText.textContent='Verarbeitung wird gestartet …'; msg('Fertige Aufnahme wird im Hintergrund erstellt …',true);
try {
const r=await fetch('/api/edit', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename,start,end,metadata:{date,artist,title,album}})});
const d=await r.json();
if(!r.ok || !d.success) throw new Error(d.error || `Fehler beim Starten (HTTP ${r.status})`);
const poll=async()=>{
const sr=await fetch('/api/edit/status/'+encodeURIComponent(d.job_id), {cache:'no-store'}); const sd=await sr.json();
if(!sr.ok || !sd.success) throw new Error(sd.error || `Statusfehler HTTP ${sr.status}`);
const percent=Math.max(0,Math.min(100,sd.progress || 0)); progressBar.style.width=percent+'%'; progressPercent.textContent=Math.round(percent)+' %';
if(sd.status==='running' || sd.status==='queued') { progressText.textContent=sd.status==='queued'?'Verarbeitung wird gestartet …':'Verarbeitung läuft …'; setTimeout(poll,700); }
else if(sd.status==='done') { progressBar.style.width='100%'; progressPercent.textContent='100 %'; progressText.textContent='Fertig'; msg('Fertige Aufnahme erfolgreich erstellt.',true); setTimeout(()=>location.reload(),800); }
else throw new Error(sd.error || 'Schneiden fehlgeschlagen');
}; poll().catch(e=>{msg(e.message,false);btn.disabled=false;progressText.textContent='Fehler';});
} catch(e) { msg(e.message,false); btn.disabled=false; progressBox.classList.remove('active'); }
}
render();
</script>
</body>
</html>
"""
INDEX_HTML = """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Audio – Editor</title>
<style>
* { box-sizing:border-box; }
body { margin:0; padding:24px; background:#111; color:#eee; font-family:Arial,Helvetica,sans-serif; }
.container { max-width:1400px; margin:0 auto; }
h1 { margin:0 0 6px; }
.subtitle { color:#aaa; margin-bottom:22px; }
.section { background:#181818; border:1px solid #333; border-radius:10px; padding:18px; margin-bottom:18px; }
.filter-bar { display:grid; grid-template-columns:repeat(4,minmax(150px,1fr)); gap:10px; margin-bottom:14px; }
.filter-bar label { color:#aaa; font-size:13px; }
.filter-bar input, .filter-bar select { width:100%; margin-top:5px; padding:9px; background:#111; color:#eee; border:1px solid #555; border-radius:5px; font-size:15px; }
.filter-bar .search { grid-column:auto; }
.filter-info { color:#999; font-size:13px; margin-bottom:10px; }
.upload-bar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:16px; }
.upload-info { color:#999; font-size:13px; }
.upload-progress { display:none; flex:1 1 240px; min-width:180px; height:18px; background:#292929; border-radius:9px; overflow:hidden; }
.upload-progress.active { display:block; }
.upload-progress-bar { width:0; height:100%; background:#1769aa; transition:width .15s ease; }
table { width:100%; border-collapse:collapse; }
th,td { padding:10px 8px; border-bottom:1px solid #333; text-align:left; }
th { color:#aaa; font-weight:normal; }
.button { display:inline-block; border:0; border-radius:6px; padding:9px 13px; background:#333; color:#fff; cursor:pointer; text-decoration:none; }
.button:hover { background:#444; }
.empty { color:#999; padding:15px 0; }
.meta-dialog { position:fixed; inset:0; background:rgba(0,0,0,.78); display:none; align-items:center; justify-content:center; z-index:900; padding:20px; }
.meta-dialog.active { display:flex; }
.meta-card { width:min(560px,100%); background:#202020; border:1px solid #444; border-radius:10px; padding:22px; box-shadow:0 10px 40px #000; }
.meta-card h2 { margin-top:0; }
.meta-card label { display:block; margin:14px 0; color:#ccc; }
.meta-card input { display:block; width:100%; margin-top:6px; padding:10px; background:#111; color:#eee; border:1px solid #555; border-radius:5px; font-size:16px; }
.meta-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:20px; }
.meta-actions button { margin:0; }
.loading-overlay { position:fixed; inset:0; background:rgba(0,0,0,.78); display:none; align-items:center; justify-content:center; z-index:1000; }
.loading-overlay.active { display:flex; }
.spinner { width:54px; height:54px; border:5px solid #444; border-top-color:#fff; border-radius:50%; animation:spin .8s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
@media(max-width:900px) { .filter-bar { grid-template-columns:1fr 1fr; } .filter-bar .search { grid-column:auto; } }
@media(max-width:800px) { body { padding:12px; } table,thead,tbody,tr,th,td { display:block; } thead { display:none; } tr { padding:10px 0; border-bottom:1px solid #333; } td { border:0; padding:5px 0; } }
</style>
</head>
<body>
<div class="loading-overlay" id="loadingOverlay"><div class="spinner" aria-label="Laden"></div></div>
<div class="container">
<h1>Event Audio – Editor</h1>
<div class="subtitle">Fertige Aufnahmen aus {{ recordings_dir }}</div>
<div class="section">
<h2>Aufnahmen bearbeiten</h2>
<div class="upload-bar">
<input type="file" id="uploadFile" accept="audio/mpeg,.mp3" hidden>
<button class="button" type="button" onclick="document.getElementById('uploadFile').click()">⬆ Vortrag hochladen</button>
<span class="upload-info" id="uploadInfo">MP3-Datei vom PC auswählen</span>
<div class="upload-progress" id="uploadProgress"><div class="upload-progress-bar" id="uploadProgressBar"></div></div>
</div>
{% if recordings %}
<table>
<thead><tr><th>Dateiname</th><th>Größe</th><th>Dauer</th><th></th></tr></thead>
<tbody>
{% for item in recordings %}
<tr>
<td>{{ item.filename }}</td>
<td>{{ item.size }}</td>
<td>{{ item.duration }}</td>
<td class="actions">
<a class="button" href="{{ url_for('edit_recording', filename=item.filename) }}" onclick="showLoading()">✂ Bearbeiten</a>
<button class="button" onclick='renameFile("recording", {{ item.filename|tojson }})'>✎ Umbenennen</button>
<button class="button danger" onclick='deleteFile("recording", {{ item.filename|tojson }})'>🗑 Löschen</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}<div class="empty">Keine unbearbeiteten Aufnahmen vorhanden.</div>{% endif %}
</div>
<div class="section">
<h2>Bearbeitete Aufnahmen</h2>
<div class="filter-bar">
<label>Zeitraum<select id="dateFilter">
<option value="all">Alle</option><option value="today">Heute</option><option value="yesterday">Gestern</option>
<option value="7">Letzte 7 Tage</option><option value="30">Letzte 30 Tage</option>
<option value="month">Dieser Monat</option><option value="lastmonth">Letzter Monat</option><option value="year">Dieses Jahr</option>
</select></label>
<label>Redner<select id="artistFilter"><option value="">Alle Redner</option>{% for artist in artists %}<option value="{{ artist }}">{{ artist }}</option>{% endfor %}</select></label>
<label>Veranstaltung<select id="albumFilter"><option value="">Alle Veranstaltungen</option>{% for album in albums %}<option value="{{ album }}">{{ album }}</option>{% endfor %}</select></label>
<label class="search">Beschreibung / Volltext<input id="textFilter" type="search" placeholder="Beschreibung, Redner, Veranstaltung, Dateiname …"></label>
</div>
<div class="filter-info" id="filterInfo"></div>
{% if finished %}
<table id="finishedTable">
<thead><tr><th>Zeitpunkt</th><th>Redner</th><th>Beschreibung</th><th>Veranstaltung</th><th>Dauer</th><th></th></tr></thead>
<tbody>
{% for item in finished %}
<tr class="finished-row" data-ts="{{ item.sort_ts }}" data-artist="{{ item.artist }}" data-album="{{ item.album }}" data-search="{{ (item.filename ~ ' ' ~ item.artist ~ ' ' ~ item.title ~ ' ' ~ item.album)|lower }}">
<td>{{ item.datetime }}</td>
<td>{{ item.artist or "–" }}</td>
<td>{{ item.title or "–" }}</td>
<td>{{ item.album or "–" }}</td>
<td>{{ item.duration }}</td>
<td class="actions">
<a class="button" href="{{ url_for('finished_file', filename=item.filename) }}">▶ Abspielen / Download</a>
<button class="button" onclick='editFinishedFile({{ item.filename|tojson }}, {{ item.datetime|tojson }}, {{ item.artist|tojson }}, {{ item.title|tojson }}, {{ item.album|tojson }})'>✎ Bearbeiten</button>
<button class="button danger" onclick='deleteFile("finished", {{ item.filename|tojson }})'>🗑 Löschen</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}<div class="empty">Noch keine bearbeiteten Aufnahmen vorhanden.</div>{% endif %}
</div>
</div>
<div class="meta-dialog" id="finishedMetaDialog">
<div class="meta-card">
<h2>Bearbeitete Aufnahme ändern</h2>
<label>Dateiname<input id="finishedFilename" type="text"></label>
<label>Zeitpunkt<input id="finishedDate" type="datetime-local" required></label>
<label>Redner<input id="finishedArtist" type="text"></label>
<label>Beschreibung<input id="finishedTitle" type="text"></label>
<label>Veranstaltung<input id="finishedAlbum" type="text"></label>
<div class="meta-actions">
<button onclick="closeFinishedMetaDialog()">Abbrechen</button>
<button class="primary" onclick="saveFinishedMeta()">Speichern</button>
</div>
</div>
</div>
<script>
function applyFilters() {
const range=document.getElementById('dateFilter').value;
const artist=document.getElementById('artistFilter').value;
const album=document.getElementById('albumFilter').value;
const text=document.getElementById('textFilter').value.trim().toLowerCase();
const now=new Date();
const todayStart=new Date(now.getFullYear(),now.getMonth(),now.getDate()).getTime()/1000;
const tomorrow=todayStart+86400;
const yesterday=todayStart-86400;
const week=todayStart-6*86400;
const days30=todayStart-29*86400;
const monthStart=new Date(now.getFullYear(),now.getMonth(),1).getTime()/1000;
const lastMonthStart=new Date(now.getFullYear(),now.getMonth()-1,1).getTime()/1000;
const lastMonthEnd=monthStart;
const yearStart=new Date(now.getFullYear(),0,1).getTime()/1000;
let visible=0;
document.querySelectorAll('.finished-row').forEach(row=>{
const ts=Number(row.dataset.ts);
let dateOk=true;
if(range==='today') dateOk=ts>=todayStart;
else if(range==='yesterday') dateOk=ts>=yesterday && ts<todayStart;
else if(range==='7') dateOk=ts>=week;
else if(range==='30') dateOk=ts>=days30;
else if(range==='month') dateOk=ts>=monthStart;
else if(range==='lastmonth') dateOk=ts>=lastMonthStart && ts<lastMonthEnd;
else if(range==='year') dateOk=ts>=yearStart;
const artistOk=!artist || row.dataset.artist===artist;
const albumOk=!album || row.dataset.album===album;
const textOk=!text || row.dataset.search.includes(text);
const ok=dateOk && artistOk && albumOk && textOk;
row.style.display=ok?'':'none';
if(ok) visible++;
});
const total=document.querySelectorAll('.finished-row').length;
document.getElementById('filterInfo').textContent=`${visible} von ${total} Aufnahmen`;
}
['dateFilter','artistFilter','albumFilter','textFilter'].forEach(id=>document.getElementById(id)?.addEventListener('input',applyFilters));
applyFilters();
// File changes are pushed by the editor's filesystem watcher. No periodic
// page reload is performed. The current filters are preserved.
async function refreshFileLists() {
const activePlayers = Array.from(document.querySelectorAll('audio')).some(player =>
!player.paused || player.currentTime > 0
);
if (activePlayers) return;
const oldValues = {
date: document.getElementById('dateFilter')?.value || 'all',
artist: document.getElementById('artistFilter')?.value || '',
album: document.getElementById('albumFilter')?.value || '',
text: document.getElementById('textFilter')?.value || ''
};
try {
const response = await fetch('/', {cache:'no-store'});
if (!response.ok) return;
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const currentSections = document.querySelectorAll('.section');
const newSections = doc.querySelectorAll('.section');
if (currentSections.length < 2 || newSections.length < 2) return;
currentSections[0].replaceWith(newSections[0]);
currentSections[1].replaceWith(newSections[1]);
const date = document.getElementById('dateFilter');
const artist = document.getElementById('artistFilter');
const album = document.getElementById('albumFilter');
const text = document.getElementById('textFilter');
if (date) date.value = oldValues.date;
if (artist) artist.value = oldValues.artist;
if (album) album.value = oldValues.album;
if (text) text.value = oldValues.text;
['dateFilter','artistFilter','albumFilter','textFilter'].forEach(id =>
document.getElementById(id)?.addEventListener('input', applyFilters)
);
applyFilters();
} catch (error) {
console.warn('Dateiliste konnte nicht aktualisiert werden:', error);
}
}
const uploadFile = document.getElementById('uploadFile');
const uploadInfo = document.getElementById('uploadInfo');
const uploadProgress = document.getElementById('uploadProgress');
const uploadProgressBar = document.getElementById('uploadProgressBar');
uploadFile?.addEventListener('change', () => {
const file = uploadFile.files?.[0];
if (!file) return;
if (!file.name.toLowerCase().endsWith('.mp3')) {
alert('Bitte eine MP3-Datei auswählen.');
uploadFile.value = '';
return;
}
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
uploadProgress.classList.add('active');
uploadProgressBar.style.width = '0%';
uploadInfo.textContent = `Upload: ${file.name}`;
xhr.open('POST', '/api/upload');
xhr.upload.addEventListener('progress', event => {
if (!event.lengthComputable) return;
const percent = Math.round(event.loaded / event.total * 100);
uploadProgressBar.style.width = percent + '%';
uploadInfo.textContent = `Upload: ${file.name} – ${percent} %`;
});
xhr.addEventListener('load', () => {
let result = {};
try { result = JSON.parse(xhr.responseText); } catch (_) {}
if (xhr.status >= 200 && xhr.status < 300 && result.success) {
uploadProgressBar.style.width = '100%';
uploadInfo.textContent = `${result.filename} wurde hochgeladen.`;
setTimeout(() => location.reload(), 500);
} else {
uploadProgress.classList.remove('active');
uploadInfo.textContent = 'Upload fehlgeschlagen';
alert(result.error || `Upload fehlgeschlagen (HTTP ${xhr.status})`);
}
});
xhr.addEventListener('error', () => {
uploadProgress.classList.remove('active');
uploadInfo.textContent = 'Upload fehlgeschlagen';
alert('Netzwerkfehler beim Upload.');
});
xhr.addEventListener('abort', () => {
uploadProgress.classList.remove('active');
uploadInfo.textContent = 'Upload abgebrochen';
});
xhr.send(formData);
});
const events = new EventSource('/api/events');
events.onmessage = () => refreshFileLists();
events.onerror = () => { /* EventSource reconnects automatically. */ };
let finishedEditOriginalFilename = null;
function toDatetimeLocal(value) {
const m = String(value || '').match(/^(\\d{2})\\.(\\d{2})\\.(\\d{4}) (\\d{2}):(\\d{2}):(\\d{2})$/);
return m ? `${m[3]}-${m[2]}-${m[1]}T${m[4]}:${m[5]}` : '';
}
function editFinishedFile(filename, datetime, artist, title, album) {
finishedEditOriginalFilename = filename;
document.getElementById('finishedFilename').value = filename.replace(/\.mp3$/i, '');
document.getElementById('finishedDate').value = toDatetimeLocal(datetime);
document.getElementById('finishedArtist').value = artist || '';
document.getElementById('finishedTitle').value = title || '';
document.getElementById('finishedAlbum').value = album || '';
document.getElementById('finishedMetaDialog').classList.add('active');
}
function closeFinishedMetaDialog() {
document.getElementById('finishedMetaDialog').classList.remove('active');
}
async function saveFinishedMeta() {
const filename = finishedEditOriginalFilename;
const newFilename = document.getElementById('finishedFilename').value.trim();
const date = document.getElementById('finishedDate').value;
if (!newFilename || !date) return alert('Bitte Dateiname und Zeitpunkt angeben.');
try {
const metadata = {
date,
artist: document.getElementById('finishedArtist').value.trim(),
title: document.getElementById('finishedTitle').value.trim(),
album: document.getElementById('finishedAlbum').value.trim()
};
const response = await fetch('/api/file', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({
action: 'update_finished',
kind: 'finished',
filename,
new_filename: newFilename,
metadata
})
});
const result = await response.json();
if (!response.ok || !result.success) throw new Error(result.error || `HTTP ${response.status}`);
closeFinishedMetaDialog();
location.reload();
} catch (error) {
console.error('Ändern fehlgeschlagen:', error);
alert('Ändern fehlgeschlagen: ' + error.message);
}
}
function showLoading() { document.getElementById('loadingOverlay').classList.add('active'); }
async function renameFile(kind, filename) {
const current = filename.replace(/\\.mp3$/i, '');
const entered = prompt('Neuer Dateiname:', current);
if (entered === null) return;
const newName = entered.trim();
if (!newName || newName === current) return;
try {
const response = await fetch('/api/file', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'rename', kind, filename, new_filename: newName})
});
const result = await response.json();
if (!response.ok || !result.success) throw new Error(result.error || `HTTP ${response.status}`);
location.reload();
} catch (error) {
console.error('Umbenennen fehlgeschlagen:', error);
alert('Umbenennen fehlgeschlagen: ' + error.message);
}
}
async function deleteFile(kind, filename) {
if (!confirm('Datei wirklich löschen?\\n\\n' + filename)) return;
try {
const response = await fetch('/api/file', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'delete', kind, filename})
});
const result = await response.json();
if (!response.ok || !result.success) throw new Error(result.error || `HTTP ${response.status}`);
location.reload();
} catch (error) {
console.error('Löschen fehlgeschlagen:', error);
alert('Löschen fehlgeschlagen: ' + error.message);
}
}
</script>
</body>
</html>
"""
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.route("/")
def index():
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
recordings = []
for path in RECORDINGS_DIR.glob("*.mp3"):
try:
stat = path.stat()
recordings.append({
"filename": path.name,
"datetime": format_datetime(stat.st_mtime),
"size": format_size(stat.st_size),
"duration": format_duration(get_duration(path)),
"mtime": stat.st_mtime,
})
except FileNotFoundError:
pass
finished = []
for path in FINISHED_DIR.glob("*.mp3"):
try:
stat = path.stat()
tags = read_metadata(path)
finished.append({
"filename": path.name,
"datetime": metadata_datetime(path),
"artist": tags.get("artist", ""),
"title": tags.get("title", ""),
"album": tags.get("album", ""),
"size": format_size(stat.st_size),
"duration": format_duration(get_duration(path)),
"sort_date": tags.get("date", "") or datetime.fromtimestamp(stat.st_mtime, ZoneInfo("Europe/Berlin")).isoformat(),
"sort_ts": metadata_timestamp(path, tags),
})
except FileNotFoundError:
pass
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),
)
@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/<path:filename>")
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),
)
@app.route("/waveforms/<path:filename>")
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/<path:filename>")
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/<path:filename>")
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/<job_id>")
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)