785 lines
28 KiB
Python
785 lines
28 KiB
Python
import os
|
||
import subprocess
|
||
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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Configuration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
RECORDINGS_DIR = Path(os.environ.get("RECORDINGS_DIR", "/recordings"))
|
||
FINISHED_DIR = Path(os.environ.get("FINISHED_DIR", "/finished"))
|
||
WAVEFORMS_DIR = Path(os.environ.get("WAVEFORMS_DIR", "/waveforms"))
|
||
WEB_PORT = int(os.environ.get("WEB_PORT", "8080"))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Application
|
||
# ---------------------------------------------------------------------------
|
||
|
||
app = Flask(__name__)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 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 create_finished(filename, start, end):
|
||
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"
|
||
|
||
result = subprocess.run(
|
||
[
|
||
"ffmpeg", "-hide_banner", "-loglevel", "warning", "-y",
|
||
"-i", str(source),
|
||
"-ss", f"{start:.3f}",
|
||
"-t", f"{end - start:.3f}",
|
||
"-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
|
||
"-c:a", "libmp3lame",
|
||
"-b:a", "128k",
|
||
str(temp),
|
||
],
|
||
capture_output=True, text=True, timeout=1800,
|
||
)
|
||
|
||
if result.returncode != 0 or not temp.is_file():
|
||
try:
|
||
temp.unlink(missing_ok=True)
|
||
except OSError:
|
||
pass
|
||
print(f"[edit] ffmpeg failed for {filename}: {result.stderr.strip()}", flush=True)
|
||
raise RuntimeError("Schneiden fehlgeschlagen")
|
||
|
||
temp.replace(destination)
|
||
return destination, get_duration(destination)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# HTML
|
||
# ---------------------------------------------------------------------------
|
||
|
||
EDITOR_HTML = """
|
||
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Event Audio – Aufnahme bearbeiten</title>
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
body { margin:0; padding:24px; background:#111; color:#eee; font-family:Arial,Helvetica,sans-serif; }
|
||
.container { max-width:1400px; margin:0 auto; }
|
||
h1 { margin:0 0 6px; font-size:28px; }
|
||
.subtitle { color:#aaa; margin-bottom:22px; }
|
||
.editor { background:#181818; border:1px solid #333; border-radius:10px; padding:20px; }
|
||
.wave-wrap { position:relative; width:100%; height:240px; background:#0b0b0b; border-radius:6px; overflow:hidden; user-select:none; touch-action:none; }
|
||
.wave-wrap img { width:100%; height:100%; object-fit:fill; display:block; opacity:.9; }
|
||
.selection { position:absolute; top:0; bottom:0; background:rgba(80,150,255,.16); border-left:2px solid #fff; border-right:2px solid #fff; pointer-events:none; }
|
||
.playhead { position:absolute; top:0; bottom:0; width:2px; margin-left:-1px; background:#ffcc33; box-shadow:0 0 5px #000; z-index:4; pointer-events:none; }
|
||
.handle { position:absolute; top:0; bottom:0; width:18px; margin-left:-9px; cursor:ew-resize; z-index:3; touch-action:none; }
|
||
.handle::after { content:""; position:absolute; left:7px; top:0; bottom:0; width:4px; background:#fff; border-radius:2px; box-shadow:0 0 5px #000; }
|
||
.time-row { display:flex; justify-content:space-between; margin-top:8px; color:#aaa; font-size:14px; }
|
||
.controls { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-top:18px; }
|
||
button, a.button { border:0; border-radius:6px; padding:11px 16px; background:#333; color:#fff; cursor:pointer; text-decoration:none; font-size:15px; }
|
||
button:hover, a.button:hover { background:#444; }
|
||
.button.danger { background:#4a2020; }
|
||
.button.danger:hover { background:#6a2b2b; }
|
||
.actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||
button.primary { background:#1769aa; }
|
||
button.danger { background:#8b0000; }
|
||
button:disabled { opacity:.45; cursor:not-allowed; }
|
||
.position { margin-left:auto; color:#ccc; font-variant-numeric:tabular-nums; }
|
||
.values { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-top:20px; }
|
||
.value { background:#222; border-radius:6px; padding:12px; }
|
||
.value span { display:block; color:#999; font-size:13px; margin-bottom:5px; }
|
||
.value strong { font-size:20px; font-variant-numeric:tabular-nums; }
|
||
.actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:20px; }
|
||
.info { margin-top:16px; color:#999; font-size:14px; }
|
||
.message { margin-top:15px; padding:10px 12px; border-radius:6px; display:none; }
|
||
.message.ok { display:block; background:#173d24; color:#b8f5c9; }
|
||
.message.error { display:block; background:#4a1b1b; color:#ffd0d0; }
|
||
.back { display:inline-block; margin-bottom:18px; color:#bbb; text-decoration:none; }
|
||
@media(max-width:700px) {
|
||
body { padding:12px; }
|
||
.editor { padding:12px; }
|
||
.wave-wrap { height:180px; }
|
||
.values { grid-template-columns:1fr; }
|
||
.position { width:100%; margin-left:0; }
|
||
button, a.button { flex:1 1 auto; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<a class="back" href="/">← Zurück zu den Aufnahmen</a>
|
||
<h1>Aufnahme bearbeiten</h1>
|
||
<div class="subtitle">{{ filename }} · Originaldauer {{ duration }}</div>
|
||
|
||
<div class="editor">
|
||
<div class="wave-wrap" id="wave">
|
||
<img 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="time-row">
|
||
<span>00:00:00</span>
|
||
<span>{{ 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="playFromStart()">▶ Ab Auswahlstart</button>
|
||
<button onclick="setStartAtCurrent()">⏱ Anfang = Position</button>
|
||
<button onclick="setEndAtCurrent()">⏱ Ende = Position</button>
|
||
<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>
|
||
<button class="button danger" onclick="deleteOriginal()">🗑 Originalaufnahme löschen</button>
|
||
</div>
|
||
<div class="info">Beim Erstellen einer fertigen Aufnahme bleibt das Original unverändert. Das Original kann hier bewusst gelöscht werden; bereits erstellte fertige Aufnahmen bleiben dabei erhalten.</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;
|
||
const wave = document.getElementById('wave');
|
||
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');
|
||
|
||
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();
|
||
return Math.max(0, Math.min(duration, ((clientX-r.left)/r.width)*duration));
|
||
}
|
||
function render() {
|
||
const a = (start/duration)*100;
|
||
const b = (end/duration)*100;
|
||
startHandle.style.left = a+'%';
|
||
endHandle.style.left = b+'%';
|
||
selection.style.left = a+'%';
|
||
selection.style.width = (b-a)+'%';
|
||
document.getElementById('startValue').textContent = fmt(start);
|
||
document.getElementById('endValue').textContent = fmt(end);
|
||
document.getElementById('keepValue').textContent = fmt(end-start);
|
||
renderPlayhead();
|
||
}
|
||
function renderPlayhead() {
|
||
const t = Math.max(0, Math.min(duration, audio.currentTime || 0));
|
||
playhead.style.left = ((t/duration)*100)+'%';
|
||
}
|
||
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;
|
||
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 deleteOriginal() {
|
||
if (!confirm('Originalaufnahme wirklich löschen?\\n\\n' + filename + '\\n\\nBereits erstellte fertige Aufnahmen bleiben erhalten.')) return;
|
||
|
||
audio.pause();
|
||
const response = await fetch('/api/file', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({action: 'delete', kind: 'recording', filename})
|
||
});
|
||
const result = await response.json();
|
||
if (!result.success) {
|
||
alert(result.error || 'Löschen fehlgeschlagen.');
|
||
return;
|
||
}
|
||
location.href = '/';
|
||
}
|
||
|
||
async function trim() {
|
||
if (end-start < 0.5) return msg('Der ausgewählte Bereich ist zu kurz.',false);
|
||
if (!confirm(`Aufnahme auf ${fmt(start)} bis ${fmt(end)} kürzen?`)) return;
|
||
const btn=document.getElementById('trimButton'); btn.disabled=true; audio.pause();
|
||
msg('Aufnahme wird geschnitten …',true);
|
||
try {
|
||
const r=await fetch('/api/edit', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename,start,end})});
|
||
const d=await r.json();
|
||
if(!r.ok || !d.success) throw new Error(d.error || 'Fehler beim Schneiden');
|
||
msg('Fertige Aufnahme erfolgreich erstellt.',true);
|
||
setTimeout(()=>location.reload(),700);
|
||
} catch(e) { msg(e.message,false); btn.disabled=false; }
|
||
}
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
INDEX_HTML = """
|
||
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Event Audio – Editor</title>
|
||
<style>
|
||
* { box-sizing:border-box; }
|
||
body { margin:0; padding:24px; background:#111; color:#eee; font-family:Arial,Helvetica,sans-serif; }
|
||
.container { max-width:1400px; margin:0 auto; }
|
||
h1 { margin:0 0 6px; }
|
||
.subtitle { color:#aaa; margin-bottom:22px; }
|
||
.section { background:#181818; border:1px solid #333; border-radius:10px; padding:18px; margin-bottom:18px; }
|
||
table { width:100%; border-collapse:collapse; }
|
||
th,td { padding:10px 8px; border-bottom:1px solid #333; text-align:left; }
|
||
th { color:#aaa; font-weight:normal; }
|
||
.button { display:inline-block; border:0; border-radius:6px; padding:9px 13px; background:#333; color:#fff; cursor:pointer; text-decoration:none; }
|
||
.button:hover { background:#444; }
|
||
.empty { color:#999; padding:15px 0; }
|
||
.loading-overlay { position:fixed; inset:0; background:rgba(0,0,0,.78); display:none; align-items:center; justify-content:center; z-index:1000; }
|
||
.loading-overlay.active { display:flex; }
|
||
.spinner { width:54px; height:54px; border:5px solid #444; border-top-color:#fff; border-radius:50%; animation:spin .8s linear infinite; }
|
||
@keyframes spin { to { transform:rotate(360deg); } }
|
||
@media(max-width:800px) { body { padding:12px; } table,thead,tbody,tr,th,td { display:block; } thead { display:none; } tr { padding:10px 0; border-bottom:1px solid #333; } td { border:0; padding:5px 0; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="loading-overlay" id="loadingOverlay"><div class="spinner" aria-label="Laden"></div></div>
|
||
<div class="container">
|
||
<h1>Event Audio – Editor</h1>
|
||
<div class="subtitle">Fertige Aufnahmen aus {{ recordings_dir }}</div>
|
||
|
||
<div class="section">
|
||
<h2>Aufnahmen bearbeiten</h2>
|
||
{% if recordings %}
|
||
<table>
|
||
<thead><tr><th>Aufnahme</th><th>Datum</th><th>Größe</th><th>Dauer</th><th></th></tr></thead>
|
||
<tbody>
|
||
{% for item in recordings %}
|
||
<tr>
|
||
<td>{{ item.filename }}</td>
|
||
<td>{{ item.datetime }}</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 fertigen Aufnahmen vorhanden.</div>{% endif %}
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>Bearbeitete Aufnahmen</h2>
|
||
{% if finished %}
|
||
<table>
|
||
<thead><tr><th>Datei</th><th>Datum</th><th>Größe</th><th>Dauer</th><th></th></tr></thead>
|
||
<tbody>
|
||
{% for item in finished %}
|
||
<tr>
|
||
<td>{{ item.filename }}</td>
|
||
<td>{{ item.datetime }}</td>
|
||
<td>{{ item.size }}</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='renameFile("finished", {{ item.filename|tojson }})'>✎ Umbenennen</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>
|
||
<script>
|
||
function showLoading() { document.getElementById('loadingOverlay').classList.add('active'); }
|
||
|
||
async function renameFile(kind, filename) {
|
||
const current = filename.replace(/\\.mp3$/i, '');
|
||
const entered = prompt('Neuer Dateiname:', current);
|
||
if (entered === null) return;
|
||
|
||
const newName = entered.trim();
|
||
if (!newName || newName === current) return;
|
||
|
||
try {
|
||
const response = await fetch('/api/file', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({action: 'rename', kind, filename, new_filename: newName})
|
||
});
|
||
const result = await response.json();
|
||
if (!response.ok || !result.success) throw new Error(result.error || `HTTP ${response.status}`);
|
||
location.reload();
|
||
} catch (error) {
|
||
console.error('Umbenennen fehlgeschlagen:', error);
|
||
alert('Umbenennen fehlgeschlagen: ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function deleteFile(kind, filename) {
|
||
if (!confirm('Datei wirklich löschen?\\n\\n' + filename)) return;
|
||
|
||
try {
|
||
const response = await fetch('/api/file', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({action: 'delete', kind, filename})
|
||
});
|
||
const result = await response.json();
|
||
if (!response.ok || !result.success) throw new Error(result.error || `HTTP ${response.status}`);
|
||
location.reload();
|
||
} catch (error) {
|
||
console.error('Löschen fehlgeschlagen:', error);
|
||
alert('Löschen fehlgeschlagen: ' + error.message);
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Routes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.route("/")
|
||
def index():
|
||
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
recordings = []
|
||
for path in RECORDINGS_DIR.glob("*.mp3"):
|
||
try:
|
||
stat = path.stat()
|
||
recordings.append({
|
||
"filename": path.name,
|
||
"datetime": format_datetime(stat.st_mtime),
|
||
"size": format_size(stat.st_size),
|
||
"duration": format_duration(get_duration(path)),
|
||
"mtime": stat.st_mtime,
|
||
})
|
||
except FileNotFoundError:
|
||
pass
|
||
|
||
finished = []
|
||
for path in FINISHED_DIR.glob("*.mp3"):
|
||
try:
|
||
stat = path.stat()
|
||
finished.append({
|
||
"filename": path.name,
|
||
"datetime": format_datetime(stat.st_mtime),
|
||
"size": format_size(stat.st_size),
|
||
"duration": format_duration(get_duration(path)),
|
||
"mtime": stat.st_mtime,
|
||
})
|
||
except FileNotFoundError:
|
||
pass
|
||
|
||
recordings.sort(key=lambda x: x["mtime"], reverse=True)
|
||
finished.sort(key=lambda x: x["mtime"], reverse=True)
|
||
|
||
return render_template_string(
|
||
INDEX_HTML,
|
||
recordings=recordings,
|
||
finished=finished,
|
||
recordings_dir=str(RECORDINGS_DIR),
|
||
)
|
||
|
||
|
||
@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,
|
||
waveform_url=url_for("waveform", filename=filename),
|
||
audio_url=url_for("recording", filename=filename),
|
||
)
|
||
|
||
|
||
@app.route("/waveforms/<path:filename>")
|
||
def waveform(filename):
|
||
if source_path(filename) is None:
|
||
abort(404)
|
||
output = create_waveform(filename)
|
||
if output is None:
|
||
abort(404)
|
||
return send_from_directory(WAVEFORMS_DIR, output.name, mimetype="image/png")
|
||
|
||
|
||
@app.route("/recordings/<path:filename>")
|
||
def recording(filename):
|
||
path = source_path(filename)
|
||
if path is None:
|
||
abort(404)
|
||
return send_from_directory(RECORDINGS_DIR, path.name, mimetype="audio/mpeg")
|
||
|
||
|
||
@app.route("/finished/<path:filename>")
|
||
def finished_file(filename):
|
||
path = finished_path(filename)
|
||
if path is None or not path.is_file():
|
||
abort(404)
|
||
return send_from_directory(FINISHED_DIR, path.name, mimetype="audio/mpeg", as_attachment=False)
|
||
|
||
|
||
@app.route("/api/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 == "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")
|
||
|
||
try:
|
||
destination, duration = create_finished(filename, data.get("start"), data.get("end"))
|
||
return jsonify({
|
||
"success": True,
|
||
"filename": destination.name,
|
||
"duration": duration,
|
||
"duration_formatted": format_duration(duration),
|
||
})
|
||
except ValueError as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 400
|
||
except Exception as exc:
|
||
print(f"[edit] failed for {filename}: {exc}", flush=True)
|
||
return jsonify({"success": False, "error": "Schneiden fehlgeschlagen"}), 500
|
||
|
||
|
||
if __name__ == "__main__":
|
||
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
|
||
app.run(host="0.0.0.0", port=WEB_PORT)
|