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