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 threading
import time import time
import uuid import uuid
import json
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -224,7 +225,48 @@ def create_waveform(filename):
return None 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) source = source_path(filename)
if source is None: if source is None:
raise ValueError("Aufnahme nicht gefunden") raise ValueError("Aufnahme nicht gefunden")
@@ -249,6 +291,7 @@ def create_finished(filename, start, end, progress_callback=None):
destination = finished_path(filename) destination = finished_path(filename)
temp = FINISHED_DIR / f".{filename}.tmp.mp3" temp = FINISHED_DIR / f".{filename}.tmp.mp3"
selected_duration = end - start selected_duration = end - start
metadata = metadata or {}
cmd = [ cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
@@ -262,6 +305,10 @@ def create_finished(filename, start, end, progress_callback=None):
"-c:a", "libmp3lame", "-c:a", "libmp3lame",
"-b:a", "128k", "-b:a", "128k",
"-avoid_negative_ts", "make_zero", "-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", "-progress", "pipe:1",
"-nostats", "-nostats",
str(temp), str(temp),
@@ -322,7 +369,7 @@ def create_finished(filename, start, end, progress_callback=None):
raise raise
def run_edit_job(job_id, filename, start, end): def run_edit_job(job_id, filename, start, end, metadata):
def update(**values): def update(**values):
with JOBS_LOCK: with JOBS_LOCK:
job = JOBS.get(job_id) job = JOBS.get(job_id)
@@ -332,7 +379,7 @@ def run_edit_job(job_id, filename, start, end):
update(status="running", progress=0.0) update(status="running", progress=0.0)
try: try:
destination, duration = create_finished( destination, duration = create_finished(
filename, start, end, filename, start, end, metadata=metadata,
progress_callback=lambda value: update(progress=value), progress_callback=lambda value: update(progress=value),
) )
update(status="done", progress=100.0, filename=destination.name, 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 { margin-top:15px; padding:10px 12px; border-radius:6px; display:none; }
.message.ok { display:block; background:#173d24; color:#b8f5c9; } .message.ok { display:block; background:#173d24; color:#b8f5c9; }
.message.error { display:block; background:#4a1b1b; color:#ffd0d0; } .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 { display:none; margin-top:16px; }
.progress-box.active { display:block; } .progress-box.active { display:block; }
.progress-track { height:18px; background:#292929; border-radius:9px; overflow:hidden; } .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> <a class="button" href="{{ audio_url }}" download>Original herunterladen</a>
</div> </div>
<div class="info">Beim Erstellen einer fertigen Aufnahme bleibt das Original unverändert.</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 id="message" class="message"></div>
</div> </div>
</div> </div>
@@ -737,58 +802,35 @@ function msg(text, ok) {
async function trim() { async function trim() {
if (end-start < 0.5) return msg('Der ausgewählte Bereich ist zu kurz.',false); 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 btn=document.getElementById('trimButton');
const progressBox=document.getElementById('progressBox'); const progressBox=document.getElementById('progressBox');
const progressBar=document.getElementById('progressBar'); const progressBar=document.getElementById('progressBar');
const progressText=document.getElementById('progressText'); const progressText=document.getElementById('progressText');
const progressPercent=document.getElementById('progressPercent'); const progressPercent=document.getElementById('progressPercent');
btn.disabled=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);
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);
try { try {
const r=await fetch('/api/edit', { const r=await fetch('/api/edit', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename,start,end,metadata:{date,artist,title,album}})});
method:'POST', headers:{'Content-Type':'application/json'}, const d=await r.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)}`);
if(!r.ok || !d.success) throw new Error(d.error || `Fehler beim Starten (HTTP ${r.status})`); 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 poll=async()=>{
const sr=await fetch('/api/edit/status/'+encodeURIComponent(jobId), {cache:'no-store'}); const sr=await fetch('/api/edit/status/'+encodeURIComponent(d.job_id), {cache:'no-store'}); const sd=await sr.json();
const sd=await sr.json();
if(!sr.ok || !sd.success) throw new Error(sd.error || `Statusfehler HTTP ${sr.status}`); 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)); const percent=Math.max(0,Math.min(100,sd.progress || 0)); progressBar.style.width=percent+'%'; progressPercent.textContent=Math.round(percent)+' %';
progressBar.style.width=percent+'%'; if(sd.status==='running' || sd.status==='queued') { progressText.textContent=sd.status==='queued'?'Verarbeitung wird gestartet …':'Verarbeitung läuft …'; setTimeout(poll,700); }
progressPercent.textContent=Math.round(percent)+' %'; 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); }
if(sd.status==='running') { else throw new Error(sd.error || 'Schneiden fehlgeschlagen');
progressText.textContent='Verarbeitung läuft …'; }; poll().catch(e=>{msg(e.message,false);btn.disabled=false;progressText.textContent='Fehler';});
setTimeout(poll,700); } catch(e) { msg(e.message,false); btn.disabled=false; progressBox.classList.remove('active'); }
} 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(); render();
</script> </script>
@@ -838,9 +880,10 @@ th { color:#aaa; font-weight:normal; }
<tbody> <tbody>
{% for item in recordings %} {% for item in recordings %}
<tr> <tr>
<td>{{ item.filename }}</td>
<td>{{ item.datetime }}</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>{{ item.duration }}</td>
<td class="actions"> <td class="actions">
<a class="button" href="{{ url_for('edit_recording', filename=item.filename) }}" onclick="showLoading()">✂ Bearbeiten</a> <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> <h2>Bearbeitete Aufnahmen</h2>
{% if finished %} {% if finished %}
<table> <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> <tbody>
{% for item in finished %} {% for item in finished %}
<tr> <tr>
<td>{{ item.filename }}</td>
<td>{{ item.datetime }}</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>{{ item.duration }}</td>
<td class="actions"> <td class="actions">
<a class="button" href="{{ url_for('finished_file', filename=item.filename) }}">▶ Abspielen / Download</a> <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"): for path in FINISHED_DIR.glob("*.mp3"):
try: try:
stat = path.stat() stat = path.stat()
tags = read_metadata(path)
finished.append({ finished.append({
"filename": path.name, "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), "size": format_size(stat.st_size),
"duration": format_duration(get_duration(path)), "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: except FileNotFoundError:
pass pass
recordings.sort(key=lambda x: x["mtime"], reverse=True) 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( return render_template_string(
INDEX_HTML, INDEX_HTML,
@@ -994,6 +1042,8 @@ def edit_recording(filename):
filename=filename, filename=filename,
duration=format_duration(duration), duration=format_duration(duration),
duration_seconds=duration, duration_seconds=duration,
metadata=read_metadata(path),
metadata_datetime=metadata_form_value(path),
waveform_url=url_for("waveform", filename=filename), waveform_url=url_for("waveform", filename=filename),
audio_url=url_for("recording", filename=filename), audio_url=url_for("recording", filename=filename),
) )
@@ -1056,6 +1106,7 @@ def api_edit():
filename = data.get("filename") filename = data.get("filename")
start = data.get("start") start = data.get("start")
end = data.get("end") end = data.get("end")
metadata = data.get("metadata") or {}
source = source_path(filename) source = source_path(filename)
if source is None: if source is None:
@@ -1072,7 +1123,22 @@ def api_edit():
with JOBS_LOCK: with JOBS_LOCK:
JOBS[job_id] = {"status":"queued", "progress":0.0, "filename":filename, JOBS[job_id] = {"status":"queued", "progress":0.0, "filename":filename,
"created_at":time.time()} "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 return jsonify({"success": True, "job_id": job_id}), 202