formular für mp3-tags hinzugefügt
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user