From 21a5fe9937c4e3053c24ec3c365dc27f0a1e3492 Mon Sep 17 00:00:00 2001 From: bernd Date: Tue, 29 Sep 2026 12:16:32 +0200 Subject: [PATCH] =?UTF-8?q?formular=20f=C3=BCr=20mp3-tags=20hinzugef=C3=BC?= =?UTF-8?q?gt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- editor.py | 178 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 122 insertions(+), 56 deletions(-) diff --git a/editor.py b/editor.py index adbd3b7..df00166 100644 --- a/editor.py +++ b/editor.py @@ -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; } Original herunterladen
Beim Erstellen einer fertigen Aufnahme bleibt das Original unverändert.
-
Verarbeitung läuft …0 %
+
+
+

Metadaten der fertigen Aufnahme

+ + + + +
+
+
+
Verarbeitung läuft …0 %
@@ -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(); @@ -838,9 +880,10 @@ th { color:#aaa; font-weight:normal; } {% for item in recordings %} -{{ item.filename }} {{ item.datetime }} -{{ item.size }} +{{ item.artist or "–" }} +{{ item.title or "–" }} +{{ item.album or "–" }} {{ item.duration }} ✂ Bearbeiten @@ -858,13 +901,14 @@ th { color:#aaa; font-weight:normal; }

Bearbeitete Aufnahmen

{% if finished %} - + {% for item in finished %} - - + + +
DateiDatumGrößeDauer
ZeitpunktRednerBeschreibungVeranstaltungDauer
{{ item.filename }} {{ item.datetime }}{{ item.size }}{{ item.artist or "–" }}{{ item.title or "–" }}{{ item.album or "–" }} {{ item.duration }} ▶ Abspielen / Download @@ -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