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