Files
event-audio-recorder/editor.py
T
2026-10-02 22:08:20 +02:00

2382 lines
98 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
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
THEME_FILE = Path(os.environ.get("THEME_FILE", "/config/theme.json"))
SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "/config/settings.json"))
DEFAULT_SETTINGS = {
"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"),
"git_repo_url": os.environ.get("GIT_REPO_URL", ""),
"user_url": os.environ.get("USER_URL", ""),
"editor_url": os.environ.get("EDITOR_URL", ""),
"logo_file": os.environ.get("LOGO_FILE", ""),
"favicon_file": os.environ.get("FAVICON_FILE", ""),
}
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_settings():
"""Load UI/branding settings, falling back to environment defaults."""
settings = dict(DEFAULT_SETTINGS)
if SETTINGS_FILE.is_file():
try:
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
if isinstance(data, dict):
for key in DEFAULT_SETTINGS:
if key in data and isinstance(data[key], str):
settings[key] = data[key]
if isinstance(data.get("colors"), dict):
settings["colors"] = dict(data["colors"])
except Exception as exc:
print(f"[branding] Einstellungen konnten nicht geladen werden: {exc}", flush=True)
return settings
def load_theme():
"""Load semantic theme colors from settings, then theme.json, then defaults."""
theme = dict(DEFAULT_THEME)
# Keep the existing theme.json as a backwards-compatible fallback.
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)
settings = load_settings()
colors = settings.get("colors") if isinstance(settings.get("colors"), dict) else {}
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()
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():
settings = load_settings()
logo_file = Path(settings["logo_file"]) if settings.get("logo_file") else None
favicon_file = Path(settings["favicon_file"]) if settings.get("favicon_file") else None
return {
"site_name": settings["site_name"],
"site_subtitle": settings["site_subtitle"],
"page_title": settings["page_title"],
"page_heading": settings["page_heading"],
"edit_page_title": settings["edit_page_title"],
"edit_page_heading": settings["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,
"git_repo_url": settings["git_repo_url"],
"user_url": settings["user_url"],
"editor_url": settings["editor_url"],
}
def save_settings(data):
"""Atomically persist the editable page configuration."""
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
temp = SETTINGS_FILE.with_name(SETTINGS_FILE.name + ".tmp")
temp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(temp, SETTINGS_FILE)
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:
# Rebuild older cached waveforms when their resolution is below
# the current 9600x240 waveform resolution.
with output.open("rb") as fh:
header = fh.read(24)
if (header.startswith(b"\x89PNG\r\n\x1a\n")
and len(header) >= 24
and int.from_bytes(header[16:20], "big") == 9600
and int.from_bytes(header[20:24], "big") == 240):
return output
except (FileNotFoundError, OSError):
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=9600x240: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 = """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title }}</title>
{% if favicon_url %}<link rel="icon" href="{{ favicon_url }}">{% endif %}
<style>
{{ theme_css|safe }}
* { box-sizing: border-box; }
.site-header { display:flex; align-items:center; gap:18px; margin-bottom:24px; }
.site-logo { max-width:140px; max-height:48px; object-fit:contain; display:block; flex:0 0 auto; }
.edit-logo { width:80px !important; height:28px !important; max-width:80px !important; max-height:28px !important; min-width:80px; min-height:28px; object-fit:contain !important; display:block !important; margin:0 0 10px 0; }
.site-header h1 { margin:0; }
.footer { margin-top:24px; color:var(--color-footer); font-size:13px; text-align:center; }
.footer-links { display:flex; justify-content:center; align-items:center; gap:16px; margin-top:8px; }
.footer-links a { color:var(--color-footer); text-decoration:none; }
.footer-links a:hover { color:var(--color-text); text-decoration:underline; }
.git-icon { display:inline-block; margin-right:4px; font-size:16px; line-height:1; }
body { margin:0; padding:24px; background:var(--color-background); color:var(--color-text); font-family:Arial,Helvetica,sans-serif; }
.container { max-width:1400px; margin:0 auto; }
h1 { margin:0 0 6px; font-size:28px; }
.subtitle { color:var(--color-text-muted); margin-bottom:22px; }
.editor { background:var(--color-surface); border:1px solid var(--color-border); border-radius:10px; padding:20px; }
.wave-wrap { position:relative; width:100%; height:240px; background:var(--color-surface-dark); 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(0,52,255,.16); border-left:2px solid #ffffff; border-right:2px solid #ffffff; pointer-events:none; }
.playhead { position:absolute; top:0; bottom:0; width:2px; margin-left:-1px; background:#ffcc33; box-shadow:0 0 5px #000, 0 0 10px rgba(255,204,51,.85); 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:var(--color-white); border-radius:2px; box-shadow:0 0 5px var(--color-shadow); }
.view-scroll { position:relative; width:100%; height:14px; margin-top:5px; background:var(--color-border-subtle); 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:var(--color-text-faint); border-radius:5px; cursor:grab; touch-action:none; }
.view-scroll-thumb:hover { background:var(--color-text-muted); }
.view-scroll-thumb.dragging { background:var(--color-text-muted); cursor:grabbing; }
.time-row { display:flex; justify-content:space-between; margin-top:8px; color:var(--color-text-muted); 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:var(--color-border); color:var(--color-white); cursor:pointer; text-decoration:none; font:inherit; font-size:14px; }
button:hover, a.button:hover { background:var(--color-control-hover); }
.button.danger { background:var(--color-danger-soft); }
.button.danger:hover { background:var(--color-danger-soft-hover); }
.actions { display:flex; gap:6px; flex-wrap:wrap; align-items:center; }
.finished-table .actions {
display:flex;
align-items:center;
gap:6px;
row-gap:8px;
}
.finished-table .actions > a.button,
.finished-table .actions > button.button {
display:inline-flex;
align-items:center;
justify-content:center;
box-sizing:border-box;
height:42px;
min-height:42px;
padding:0 16px;
margin:0;
border-radius:6px;
line-height:1;
vertical-align:middle;
}
button.primary { background:var(--color-primary); }
button.danger { background:var(--color-danger); }
button:disabled { opacity:.45; cursor:not-allowed; }
.position { margin-left:auto; color:var(--color-text-secondary); font-variant-numeric:tabular-nums; }
.zoom { color:var(--color-text-muted); font-variant-numeric:tabular-nums; }
.values { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-top:20px; }
.value { background:var(--color-surface-alt); border-radius:6px; padding:12px; }
.value span { display:block; color:var(--color-text-muted); 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:var(--color-text-muted); font-size:14px; }
.message { margin-top:15px; padding:10px 12px; border-radius:6px; display:none; }
.message.ok { display:block; background:var(--color-success); color:var(--color-success-text); }
.message.error { display:block; background:var(--color-error); color:var(--color-error-text); }
.meta-dialog { position:fixed; inset:0; background:var(--color-overlay); 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:var(--color-surface-modal); border:1px solid var(--color-control-hover); border-radius:10px; padding:22px; box-shadow:0 10px 40px var(--color-shadow); }
.meta-card h2 { margin-top:0; }
.meta-card label { display:block; margin:14px 0; color:var(--color-text-secondary); }
.meta-card input { display:block; width:100%; margin-top:6px; padding:10px; background:var(--color-background); color:var(--color-text); border:1px solid var(--color-control-hover); 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:var(--color-border-subtle); border-radius:9px; overflow:hidden; }
.progress-bar { height:100%; width:0; background:var(--color-primary); transition:width .2s ease; }
.progress-info { display:flex; justify-content:space-between; margin-top:7px; color:var(--color-text-muted); font-size:14px; font-variant-numeric:tabular-nums; }
.back { display:inline-block; margin-bottom:18px; color:var(--color-text-secondary); 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; }
}
#dynamic-content {
background: var(--color-surface-dark);
color: var(--color-white);
border: 1px solid var(--color-primary);
border-radius: 8px;
padding: 16px;
margin: 0 0 24px 0;
box-shadow: 0 2px 6px var(--color-shadow);
}
</style>
</head>
<body>
<div class="container">
<a class="back" href="/">← Zurück zu den Aufnahmen</a>
{% if logo_url %}<img class="edit-logo" src="{{ logo_url }}" alt="{{ site_name }}" style="width:80px !important;height:28px !important;max-width:80px !important;max-height:28px !important;object-fit:contain !important;">{% endif %}
<h1>{{ edit_page_heading }}</h1>
<div class="subtitle">{{ site_name }} · {{ 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));
});
// Keep keyboard playback controls available after clicking the waveform.
// Space toggles the audio regardless of which waveform element was clicked.
document.addEventListener('keydown', e => {
if (e.code !== 'Space') return;
const target = e.target;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable)) return;
e.preventDefault();
if (audio.paused) {
audio.play().catch(() => {});
} else {
audio.pause();
}
});
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>
<div class="footer">
{% if git_repo_url or user_url %}
<div class="footer-links">
{% if git_repo_url %}
<a href="{{ git_repo_url }}" target="_blank" rel="noopener noreferrer">
<span class="git-icon" aria-hidden="true">⎇</span> App auf Git
</a>
{% endif %}
{% if user_url %}
<a href="{{ user_url }}">Benutzerseite</a>
{% endif %}
<a href="/settings">⚙ Einstellungen</a>
</div>
{% endif %}
</div>
</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>{{ page_title }}</title>
{% if favicon_url %}<link rel="icon" href="{{ favicon_url }}">{% endif %}
<style>
{{ theme_css|safe }}
* { box-sizing:border-box; }
body { margin:0; padding:24px; background:var(--color-background); color:var(--color-text); font-family:Arial,Helvetica,sans-serif; }
.container { max-width:1400px; margin:0 auto; }
h1 { margin:0 0 6px; }
.site-header { display:flex; align-items:center; gap:18px; margin-bottom:24px; flex-wrap:nowrap; }
.site-logo { max-width:140px; max-height:48px; object-fit:contain; display:block; flex:0 0 auto; }
.site-header h1 { margin:0; flex:1 1 auto; min-width:0; }
.subtitle { color:var(--color-text-muted); margin-bottom:22px; }
.section { background:var(--color-surface-dark); color:var(--color-white); border:1px solid var(--color-primary); border-radius:8px; padding:16px; margin:0 0 24px 0; box-shadow:0 2px 6px var(--color-shadow); }
.filter-bar { display:grid; grid-template-columns:repeat(4,minmax(150px,1fr)); gap:10px; margin-bottom:14px; }
.filter-bar label { color:var(--color-text-muted); font-size:13px; }
.filter-bar input, .filter-bar select { width:100%; margin-top:5px; padding:9px; background:var(--color-background); color:var(--color-text); border:1px solid var(--color-control-hover); border-radius:5px; font-size:15px; }
.filter-bar .search { grid-column:auto; }
.filter-info { color:var(--color-text-muted); font-size:13px; margin-bottom:10px; }
.upload-bar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:16px; }
.upload-info { color:var(--color-text-muted); font-size:13px; }
.upload-progress { display:none; flex:1 1 240px; min-width:180px; height:18px; background:var(--color-border-subtle); border-radius:9px; overflow:hidden; }
.upload-progress.active { display:block; }
.upload-progress-bar { width:0; height:100%; background:var(--color-primary); transition:width .15s ease; }
table { width:100%; border-collapse:collapse; background:var(--color-surface); border-radius:8px; overflow:hidden; }
th,td { padding:10px 8px; border-bottom:1px solid var(--color-border); text-align:left; }
td { color:var(--color-table-text); }
th { color:var(--color-text-muted); font-weight:normal; }
.button { display:inline-flex; align-items:center; justify-content:center; height:42px; min-height:42px; border:0; border-radius:6px; padding:0 13px; background:var(--color-border); color:var(--color-white); cursor:pointer; text-decoration:none; font:inherit; font-size:14px; line-height:1; }
.button:hover { background:var(--color-control-hover); }
.empty { color:var(--color-text-muted); padding:15px 0; }
.footer { margin-top:24px; color:var(--color-footer); font-size:13px; text-align:center; }
.footer-links { display:flex; justify-content:center; align-items:center; gap:16px; margin-top:8px; }
.footer-links a { color:var(--color-footer); text-decoration:none; }
.footer-links a:hover { color:var(--color-text); text-decoration:underline; }
.git-icon { display:inline-block; margin-right:4px; font-size:16px; line-height:1; }
.meta-dialog { position:fixed; inset:0; background:var(--color-overlay); 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:var(--color-surface-modal); border:1px solid var(--color-control-hover); border-radius:10px; padding:22px; box-shadow:0 10px 40px var(--color-shadow); }
.meta-card h2 { margin-top:0; }
.meta-card label { display:block; margin:14px 0; color:var(--color-text-secondary); }
.meta-card input { display:block; width:100%; margin-top:6px; padding:10px; background:var(--color-background); color:var(--color-text); border:1px solid var(--color-control-hover); 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:var(--color-overlay); display:none; align-items:center; justify-content:center; z-index:1000; }
.loading-overlay.active { display:flex; }
.spinner { width:54px; height:54px; border:5px solid var(--color-control-hover); border-top-color:var(--color-white); 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:1200px) {
.finished-table td:last-child {
display:flex;
align-items:center;
column-gap:8px;
row-gap:8px;
flex-wrap:wrap;
}
}
@media (max-width:800px) {
body { padding:12px; }
.finished-table, .finished-table tbody, .finished-table tr, .finished-table td { display:block; }
.finished-table { background:transparent; border:0; overflow:visible; }
.finished-table thead { display:none; }
.finished-table tbody { display:grid; gap:18px; }
.finished-table tr { display:grid; grid-template-columns:1fr auto; grid-template-areas:"event date" "title title" "speaker speaker" "duration duration" "actions actions"; gap:0 12px; margin:0; padding:20px; background:var(--color-surface); border:1px solid var(--color-border); border-radius:20px; box-shadow:0 8px 24px var(--color-shadow); }
.finished-table td { border:0; padding:0; min-width:0; }
.finished-table td:nth-child(4) { grid-area:event; justify-self:start; align-self:start; padding:6px 10px; color:var(--color-primary-hover); background:var(--color-selection); border-radius:999px; font-size:12px; font-weight:700; line-height:1.2; }
.finished-table td:nth-child(1) { grid-area:date; justify-self:end; align-self:center; color:var(--color-text-muted); font-size:12px; white-space:nowrap; }
.finished-table td:nth-child(3) { grid-area:title; margin-top:16px; color:var(--color-text); font-size:20px; font-weight:750; line-height:1.25; }
.finished-table td:nth-child(2) { grid-area:speaker; margin-top:6px; color:var(--color-text-muted); font-size:14px; font-weight:600; }
.finished-table td:nth-child(5) { grid-area:duration; margin-top:16px; color:var(--color-text-muted); font-size:13px; }
.finished-table td:nth-child(5)::before { content:"⏱ "; font-size:14px; }
.finished-table td:nth-child(6) { grid-area:actions; display:flex; align-items:center; gap:8px; row-gap:8px; flex-wrap:wrap; margin-top:14px; padding-top:14px; border-top:1px solid var(--color-border-subtle); }
.finished-table td:nth-child(6) > .button,
.finished-table td:nth-child(6) > a.button { margin-bottom:8px; }
.finished-table audio { width:100%; height:42px; margin:0; }
.finished-table a.button { display:inline-flex; align-items:center; justify-content:center; height:42px; min-height:42px; padding:0 16px; margin:0; border-radius:6px; }
.recordings-table, .recordings-table tbody, .recordings-table tr, .recordings-table td { display:block; }
.recordings-table { background:transparent; border:0; overflow:visible; }
.recordings-table thead { display:none; }
.recordings-table tbody { display:grid; gap:14px; }
.recordings-table tr { padding:16px; background:var(--color-surface); border:1px solid var(--color-border); border-radius:16px; box-shadow:0 6px 18px var(--color-shadow); }
.recordings-table td { display:grid; grid-template-columns:72px minmax(0,1fr); gap:10px; align-items:baseline; border:0; padding:6px 0; min-width:0; }
.recordings-table td::before { color:var(--color-text-muted); font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.04em; }
.recordings-table td:nth-child(1)::before { content:"Dateiname"; }
.recordings-table td:nth-child(2)::before { content:"Größe"; }
.recordings-table td:nth-child(3)::before { content:"Dauer"; }
.recordings-table td:nth-child(4) { display:block; margin-top:8px; padding-top:12px; border-top:1px solid var(--color-border-subtle); }
.recordings-table td:nth-child(4)::before { content:none; }
.recordings-table audio { width:100%; }
.recordings-table td.actions { display:flex; gap:8px; row-gap:8px; flex-wrap:wrap; }
@media (max-width:520px) {
.recordings-table td.actions,
.finished-table td.actions {
display:grid;
grid-template-columns:1fr;
row-gap:8px;
column-gap:0;
}
.recordings-table td.actions > .button,
.recordings-table td.actions > a.button,
.finished-table td.actions > .button,
.finished-table td.actions > a.button {
width:100%;
margin:0 !important;
}
}
.live-panel { display:block; }
.live-item + .live-item { border-left:0; border-top:1px solid var(--color-recording-border); padding-left:0; padding-top:16px; margin-top:16px; }
a.button { margin-left:0; margin-right:0; }
}
</style>
</head>
<body>
<div class="loading-overlay" id="loadingOverlay"><div class="spinner" aria-label="Laden"></div></div>
<div class="container">
<div class="site-header">
{% if logo_url %}<img class="site-logo" src="{{ logo_url }}" alt="{{ site_name }}">{% endif %}
<h1>{{ page_heading }}</h1>
<a class="button" href="/settings">⚙ Einstellungen</a>
</div>
<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 class="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" class="finished-table">
<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>
<div class="footer">
{% if git_repo_url or user_url %}
<div class="footer-links">
{% if git_repo_url %}
<a href="{{ git_repo_url }}" target="_blank" rel="noopener noreferrer">
<span class="git-icon" aria-hidden="true">⎇</span> App auf Git
</a>
{% endif %}
{% if user_url %}
<a href="{{ user_url }}">Benutzerseite</a>
{% endif %}
</div>
{% endif %}
</div>
</body>
</html>
"""
SETTINGS_HTML = """
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Einstellungen – {{ site_name }}</title>
{% if favicon_url %}<link rel="icon" href="{{ favicon_url }}">{% endif %}
<style>
{{ theme_css|safe }}
* { box-sizing:border-box; }
body { margin:0; padding:24px; background:var(--color-background); color:var(--color-text); font-family:system-ui,-apple-system,sans-serif; }
.container { max-width:1100px; margin:0 auto; }
a { color:var(--color-text-secondary); }
.header { display:flex; align-items:center; gap:14px; margin-bottom:24px; flex-wrap:wrap; }
.header h1 { margin:0; flex:1; }
.logo { max-width:140px; max-height:48px; object-fit:contain; }
.back { text-decoration:none; }
.section { background:var(--color-surface); border:1px solid var(--color-border); border-radius:8px; padding:18px; margin-bottom:18px; }
.section h2 { margin:0 0 16px; font-size:20px; }
.grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px 18px; }
.field label { display:block; color:var(--color-text-muted); font-size:13px; margin-bottom:5px; }
.field input { width:100%; padding:9px 10px; background:var(--color-control); color:var(--color-text); border:1px solid var(--color-control-hover); border-radius:5px; font-size:15px; }
.field.full { grid-column:1/-1; }
.color-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px 18px; }
.color-field { display:grid; grid-template-columns:1fr 52px; gap:8px; align-items:end; }
.color-field .text-wrap { min-width:0; }
.color-field input[type=color] { width:52px; height:40px; padding:3px; background:var(--color-control); border:1px solid var(--color-control-hover); border-radius:5px; }
.actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:20px; }
button { border:0; border-radius:5px; padding:10px 16px; background:var(--color-primary); color:var(--color-white); font-size:15px; cursor:pointer; }
button:hover { background:var(--color-primary-hover); }
.note { color:var(--color-text-muted); font-size:13px; margin-top:8px; }
.message { padding:10px 12px; border-radius:6px; margin-bottom:16px; background:var(--color-success); color:var(--color-success-text); }
@media(max-width:700px) { body { padding:12px; } .grid,.color-grid { grid-template-columns:1fr; } .field.full { grid-column:auto; } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<a class="back" href="/">← Editor</a>
{% if logo_url %}<img class="logo" src="{{ logo_url }}" alt="{{ site_name }}">{% endif %}
<h1>Einstellungen</h1>
</div>
{% if saved %}<div class="message">Einstellungen wurden gespeichert.</div>{% endif %}
<form method="post" action="/settings">
<div class="section">
<h2>Seiten und Branding</h2>
<div class="grid">
<div class="field"><label for="site_name">Name</label><input id="site_name" name="site_name" value="{{ settings.site_name }}"></div>
<div class="field"><label for="site_subtitle">Untertitel</label><input id="site_subtitle" name="site_subtitle" value="{{ settings.site_subtitle }}"></div>
<div class="field"><label for="page_title">Seitentitel Benutzerseite</label><input id="page_title" name="page_title" value="{{ settings.page_title }}"></div>
<div class="field"><label for="page_heading">Überschrift Benutzerseite</label><input id="page_heading" name="page_heading" value="{{ settings.page_heading }}"></div>
<div class="field"><label for="edit_page_title">Seitentitel Editor</label><input id="edit_page_title" name="edit_page_title" value="{{ settings.edit_page_title }}"></div>
<div class="field"><label for="edit_page_heading">Überschrift Aufnahme-Editor</label><input id="edit_page_heading" name="edit_page_heading" value="{{ settings.edit_page_heading }}"></div>
<div class="field"><label for="git_repo_url">Git-Repository URL</label><input id="git_repo_url" name="git_repo_url" value="{{ settings.git_repo_url }}" placeholder="https://…"></div>
<div class="field"><label for="user_url">Benutzerseite URL</label><input id="user_url" name="user_url" value="{{ settings.user_url }}" placeholder="https://…"></div>
<div class="field"><label for="editor_url">Editor URL</label><input id="editor_url" name="editor_url" value="{{ settings.editor_url }}" placeholder="https://…"></div>
<div class="field"><label for="logo_file">Logo-Datei</label><input id="logo_file" name="logo_file" value="{{ settings.logo_file }}" placeholder="/config/logo.svg"></div>
<div class="field"><label for="favicon_file">Favicon-Datei</label><input id="favicon_file" name="favicon_file" value="{{ settings.favicon_file }}" placeholder="/config/favicon.svg"></div>
</div>
<div class="note">Logo und Favicon werden nicht hochgeladen, sondern als Pfad innerhalb des Containers angegeben.</div>
</div>
<div class="section">
<h2>Farbschema</h2>
<div class="color-grid">
{% for key, label, value, picker in color_fields %}
<div class="color-field">
<div class="text-wrap"><label for="color_{{ key }}">{{ label }}</label><input id="color_{{ key }}" name="color_{{ key }}" value="{{ value }}"></div>
{% if picker %}<input type="color" value="{{ picker }}" data-target="color_{{ key }}" aria-label="{{ label }} Farbe">{% else %}<span></span>{% endif %}
</div>
{% endfor %}
</div>
<div class="note">Erlaubt sind Hex-Farben sowie rgba()/rgb()-Werte. Änderungen werden nach dem Speichern auf Editor und Benutzerseite verwendet.</div>
</div>
<div class="actions"><button type="submit">Einstellungen speichern</button><a class="back" href="/">Abbrechen</a></div>
</form>
</div>
<script>
document.querySelectorAll('input[type=color][data-target]').forEach(picker => {
picker.addEventListener('input', () => {
const target = document.getElementById(picker.dataset.target);
if (target) target.value = picker.value;
});
});
</script>
</body>
</html>
"""
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
COLOR_LABELS = {
"background": "Hintergrund",
"surface": "Fläche",
"surface_alt": "Alternative Fläche",
"surface_dark": "Dunkle Fläche",
"text": "Text",
"text_secondary": "Sekundärtext",
"text_muted": "Gedämpfter Text",
"text_faint": "Schwacher Text",
"border": "Rahmen",
"border_subtle": "Feiner Rahmen",
"control": "Steuerelement",
"control_hover": "Steuerelement Hover",
"primary": "Primärfarbe",
"primary_hover": "Primärfarbe Hover",
"accent": "Akzent",
"success": "Erfolg",
"success_text": "Erfolgstext",
"warning": "Warnung",
"danger": "Gefahr",
"danger_soft": "Gefahr Fläche",
"danger_soft_hover": "Gefahr Fläche Hover",
"error": "Fehler",
"error_text": "Fehlertext",
"recording": "Aufnahme",
"recording_border": "Aufnahme Rahmen",
"selection": "Auswahl",
"shadow": "Schatten",
"scroll_thumb": "Scrollbalken",
"scroll_thumb_hover": "Scrollbalken Hover",
"scroll_thumb_active": "Scrollbalken aktiv",
"live_surface": "Live-Fläche",
"table_header": "Tabellenkopf",
"table_text": "Tabellentext",
"footer": "Footer",
"white": "Weiß",
"surface_modal": "Dialogfläche",
"overlay": "Overlay",
}
def build_color_fields(colors):
fields = []
for key in DEFAULT_THEME:
value = colors.get(key, DEFAULT_THEME[key]) if isinstance(colors, dict) else DEFAULT_THEME[key]
picker = value if re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None
fields.append((key, COLOR_LABELS.get(key, key), value, picker))
return fields
@app.route("/settings", methods=["GET", "POST"])
def settings():
settings = load_settings()
saved = False
if request.method == "POST":
for key in DEFAULT_SETTINGS:
settings[key] = request.form.get(key, "").strip()
colors = {}
current_theme = load_theme()
for key in DEFAULT_THEME:
value = request.form.get(f"color_{key}", current_theme[key]).strip()
if re.fullmatch(r"(?:#[0-9a-fA-F]{3,8}|rgba?\([^)]*\))", value):
colors[key] = value
else:
colors[key] = current_theme[key]
settings["colors"] = colors
try:
save_settings(settings)
except OSError as exc:
return render_template_string(
SETTINGS_HTML,
settings=settings,
saved=False,
color_fields=build_color_fields(colors),
**branding_context(),
error=f"Einstellungen konnten nicht gespeichert werden: {exc}",
), 500
saved = True
colors = settings.get("colors") if isinstance(settings.get("colors"), dict) else load_theme()
return render_template_string(
SETTINGS_HTML,
settings=settings,
saved=saved,
color_fields=build_color_fields(colors),
**branding_context(),
)
@app.route("/branding/logo")
def branding_logo():
settings = load_settings()
path = Path(settings["logo_file"]) if settings.get("logo_file") else None
if path is None or not path.is_file():
abort(404)
return send_file(path)
@app.route("/branding/favicon")
def branding_favicon():
settings = load_settings()
path = Path(settings["favicon_file"]) if settings.get("favicon_file") else None
if path is None or not path.is_file():
abort(404)
return send_file(path)
@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/<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),
**branding_context(),
)
@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)