formular für mp3-tags hinzugefügt

This commit is contained in:
2026-09-29 12:16:32 +02:00
parent e1dcf9c49d
commit 21a5fe9937
+122 -56
View File
@@ -3,6 +3,7 @@ import subprocess
import threading
import time
import uuid
import json
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
@@ -224,7 +225,48 @@ def create_waveform(filename):
return None
def create_finished(filename, start, end, progress_callback=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_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")
@@ -249,6 +291,7 @@ def create_finished(filename, start, end, progress_callback=None):
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",
@@ -262,6 +305,10 @@ def create_finished(filename, start, end, progress_callback=None):
"-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),
@@ -322,7 +369,7 @@ def create_finished(filename, start, end, progress_callback=None):
raise
def run_edit_job(job_id, filename, start, end):
def run_edit_job(job_id, filename, start, end, metadata):
def update(**values):
with JOBS_LOCK:
job = JOBS.get(job_id)
@@ -332,7 +379,7 @@ def run_edit_job(job_id, filename, start, end):
update(status="running", progress=0.0)
try:
destination, duration = create_finished(
filename, start, end,
filename, start, end, metadata=metadata,
progress_callback=lambda value: update(progress=value),
)
update(status="done", progress=100.0, filename=destination.name,
@@ -391,6 +438,14 @@ button:disabled { opacity:.45; cursor:not-allowed; }
.message { margin-top:15px; padding:10px 12px; border-radius:6px; display:none; }
.message.ok { display:block; background:#173d24; color:#b8f5c9; }
.message.error { display:block; background:#4a1b1b; color:#ffd0d0; }
.meta-dialog { position:fixed; inset:0; background:rgba(0,0,0,.78); display:none; align-items:center; justify-content:center; z-index:900; padding:20px; }
.meta-dialog.active { display:flex; }
.meta-card { width:min(560px,100%); background:#202020; border:1px solid #444; border-radius:10px; padding:22px; box-shadow:0 10px 40px #000; }
.meta-card h2 { margin-top:0; }
.meta-card label { display:block; margin:14px 0; color:#ccc; }
.meta-card input { display:block; width:100%; margin-top:6px; padding:10px; background:#111; color:#eee; border:1px solid #555; border-radius:5px; font-size:16px; }
.meta-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:20px; }
.meta-actions button { margin:0; }
.progress-box { display:none; margin-top:16px; }
.progress-box.active { display:block; }
.progress-track { height:18px; background:#292929; border-radius:9px; overflow:hidden; }
@@ -460,7 +515,17 @@ button:disabled { opacity:.45; cursor:not-allowed; }
<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="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 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>
@@ -737,58 +802,35 @@ function msg(text, ok) {
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;
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('Aufnahme wird im Hintergrund geschnitten …',true);
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})
});
const contentType = r.headers.get('content-type') || '';
let d;
if (contentType.includes('application/json')) d = await r.json();
else throw new Error(`Serverfehler HTTP ${r.status}: ${(await r.text()).slice(0,200)}`);
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 jobId=d.job_id;
const poll=async()=>{
const sr=await fetch('/api/edit/status/'+encodeURIComponent(jobId), {cache:'no-store'});
const sd=await sr.json();
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') {
progressText.textContent='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');
}
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>
@@ -838,9 +880,10 @@ th { color:#aaa; font-weight:normal; }
<tbody>
{% for item in recordings %}
<tr>
<td>{{ item.filename }}</td>
<td>{{ item.datetime }}</td>
<td>{{ item.size }}</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('edit_recording', filename=item.filename) }}" onclick="showLoading()">✂ Bearbeiten</a>
@@ -858,13 +901,14 @@ th { color:#aaa; font-weight:normal; }
<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>
<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>
<td>{{ item.filename }}</td>
<td>{{ item.datetime }}</td>
<td>{{ item.size }}</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>
@@ -954,18 +998,22 @@ def index():
for path in FINISHED_DIR.glob("*.mp3"):
try:
stat = path.stat()
tags = read_metadata(path)
finished.append({
"filename": path.name,
"datetime": format_datetime(stat.st_mtime),
"datetime": tags.get("date", "") or format_datetime(stat.st_mtime),
"artist": tags.get("artist", ""),
"title": tags.get("title", ""),
"album": tags.get("album", ""),
"size": format_size(stat.st_size),
"duration": format_duration(get_duration(path)),
"mtime": stat.st_mtime,
"sort_date": tags.get("date", "") or datetime.fromtimestamp(stat.st_mtime, ZoneInfo("Europe/Berlin")).isoformat(),
})
except FileNotFoundError:
pass
recordings.sort(key=lambda x: x["mtime"], reverse=True)
finished.sort(key=lambda x: x["mtime"], reverse=True)
finished.sort(key=lambda x: x["sort_date"], reverse=True)
return render_template_string(
INDEX_HTML,
@@ -994,6 +1042,8 @@ def edit_recording(filename):
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),
)
@@ -1056,6 +1106,7 @@ def api_edit():
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:
@@ -1072,7 +1123,22 @@ def api_edit():
with JOBS_LOCK:
JOBS[job_id] = {"status":"queued", "progress":0.0, "filename":filename,
"created_at":time.time()}
threading.Thread(target=run_edit_job, args=(job_id, filename, start_f, end_f), daemon=True).start()
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