Suchfunktion eingebaut

This commit is contained in:
2026-09-29 17:06:59 +02:00
parent ebb5dde4fb
commit 424c478f69
+240 -5
View File
@@ -8,7 +8,10 @@ from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from flask import Flask, abort, jsonify, render_template_string, request, send_from_directory, url_for
from flask import Flask, abort, jsonify, render_template_string, request, send_from_directory, url_for, Response, stream_with_context
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
# ---------------------------------------------------------------------------
@@ -28,6 +31,10 @@ NORMALIZE_LRA = os.environ.get("NORMALIZE_LRA", "11")
JOBS = {}
JOBS_LOCK = threading.Lock()
SSE_CLIENTS = set()
SSE_LOCK = threading.Lock()
FILE_OBSERVER = None
# ---------------------------------------------------------------------------
# Application
@@ -355,6 +362,20 @@ def metadata_datetime(path):
return format_datetime(path.stat().st_mtime)
def metadata_timestamp(path, tags=None):
tags = tags if tags is not None else read_metadata(path)
value = tags.get("date", "")
if value:
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("Europe/Berlin"))
return dt.astimezone(ZoneInfo("Europe/Berlin")).timestamp()
except ValueError:
pass
return path.stat().st_mtime
def metadata_form_value(path):
tags = read_metadata(path)
value = tags.get("date", "")
@@ -490,6 +511,96 @@ def run_edit_job(job_id, filename, start, end, metadata):
update(status="error", error=str(exc), finished_at=time.time())
# ---------------------------------------------------------------------------
# Browser update events
# ---------------------------------------------------------------------------
def broadcast_event(event, data=None):
payload = json.dumps({"event": event, **(data or {})}, ensure_ascii=False)
dead = []
with SSE_LOCK:
for queue in list(SSE_CLIENTS):
try:
queue.put_nowait(payload)
except Exception:
dead.append(queue)
for queue in dead:
SSE_CLIENTS.discard(queue)
def sse_stream():
import queue
client_queue = queue.Queue()
with SSE_LOCK:
SSE_CLIENTS.add(client_queue)
try:
yield ": connected\n\n"
while True:
try:
payload = client_queue.get(timeout=20)
yield f"data: {payload}\n\n"
except queue.Empty:
yield ": keepalive\n\n"
finally:
with SSE_LOCK:
SSE_CLIENTS.discard(client_queue)
class RecordingFileEventHandler(FileSystemEventHandler):
"""Turn recording/finished filesystem changes into browser events.
Only meaningful MP3 file lifecycle events are forwarded. In particular,
continuous writes to an active recording are deliberately ignored so the
browser is not flooded while FFmpeg is recording.
"""
def _is_relevant(self, path):
path = Path(path)
if path.name.startswith("."):
return False
return path.suffix.lower() == ".mp3"
def on_created(self, event):
if event.is_directory or not self._is_relevant(event.src_path):
return
broadcast_event("files_changed", {"action": "created", "filename": Path(event.src_path).name})
def on_deleted(self, event):
if event.is_directory or not self._is_relevant(event.src_path):
return
broadcast_event("files_changed", {"action": "deleted", "filename": Path(event.src_path).name})
def on_moved(self, event):
if event.is_directory:
return
if self._is_relevant(event.src_path) or self._is_relevant(event.dest_path):
broadcast_event("files_changed", {"action": "moved", "filename": Path(event.dest_path).name})
def on_closed(self, event):
# watchdog's Linux/inotify backend emits this when FFmpeg closes the
# recording. That is the useful "recording finished" signal.
if event.is_directory or not self._is_relevant(event.src_path):
return
broadcast_event("files_changed", {"action": "closed", "filename": Path(event.src_path).name})
def start_file_observer():
global FILE_OBSERVER
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
observer = Observer()
handler = RecordingFileEventHandler()
observer.schedule(handler, str(RECORDINGS_DIR), recursive=False)
observer.schedule(handler, str(FINISHED_DIR), recursive=False)
observer.start()
FILE_OBSERVER = observer
print("[watcher] Monitoring recordings and finished directories", flush=True)
# ---------------------------------------------------------------------------
# HTML
# ---------------------------------------------------------------------------
@@ -954,6 +1065,11 @@ body { margin:0; padding:24px; background:#111; color:#eee; font-family:Arial,He
h1 { margin:0 0 6px; }
.subtitle { color:#aaa; margin-bottom:22px; }
.section { background:#181818; border:1px solid #333; border-radius:10px; padding:18px; margin-bottom:18px; }
.filter-bar { display:grid; grid-template-columns:repeat(4,minmax(150px,1fr)); gap:10px; margin-bottom:14px; }
.filter-bar label { color:#aaa; font-size:13px; }
.filter-bar input, .filter-bar select { width:100%; margin-top:5px; padding:9px; background:#111; color:#eee; border:1px solid #555; border-radius:5px; font-size:15px; }
.filter-bar .search { grid-column:span 2; }
.filter-info { color:#999; font-size:13px; margin-bottom:10px; }
table { width:100%; border-collapse:collapse; }
th,td { padding:10px 8px; border-bottom:1px solid #333; text-align:left; }
th { color:#aaa; font-weight:normal; }
@@ -972,6 +1088,7 @@ th { color:#aaa; font-weight:normal; }
.loading-overlay.active { display:flex; }
.spinner { width:54px; height:54px; border:5px solid #444; border-top-color:#fff; border-radius:50%; animation:spin .8s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
@media(max-width:900px) { .filter-bar { grid-template-columns:1fr 1fr; } .filter-bar .search { grid-column:span 2; } }
@media(max-width:800px) { body { padding:12px; } table,thead,tbody,tr,th,td { display:block; } thead { display:none; } tr { padding:10px 0; border-bottom:1px solid #333; } td { border:0; padding:5px 0; } }
</style>
</head>
@@ -1006,12 +1123,23 @@ th { color:#aaa; font-weight:normal; }
<div class="section">
<h2>Bearbeitete Aufnahmen</h2>
<div class="filter-bar">
<label>Zeitraum<select id="dateFilter">
<option value="all">Alle</option><option value="today">Heute</option><option value="yesterday">Gestern</option>
<option value="7">Letzte 7 Tage</option><option value="30">Letzte 30 Tage</option>
<option value="month">Dieser Monat</option><option value="lastmonth">Letzter Monat</option><option value="year">Dieses Jahr</option>
</select></label>
<label>Redner<select id="artistFilter"><option value="">Alle Redner</option>{% for artist in artists %}<option value="{{ artist }}">{{ artist }}</option>{% endfor %}</select></label>
<label>Veranstaltung<select id="albumFilter"><option value="">Alle Veranstaltungen</option>{% for album in albums %}<option value="{{ album }}">{{ album }}</option>{% endfor %}</select></label>
<label class="search">Beschreibung / Volltext<input id="textFilter" type="search" placeholder="Beschreibung, Redner, Veranstaltung, Dateiname …"></label>
</div>
<div class="filter-info" id="filterInfo"></div>
{% if finished %}
<table>
<table id="finishedTable">
<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>
<tr class="finished-row" data-ts="{{ item.sort_ts }}" data-artist="{{ item.artist }}" data-album="{{ item.album }}" data-search="{{ (item.filename ~ ' ' ~ item.artist ~ ' ' ~ item.title ~ ' ' ~ item.album)|lower }}">
<td>{{ item.datetime }}</td>
<td>{{ item.artist or "–" }}</td>
<td>{{ item.title or "–" }}</td>
@@ -1045,10 +1173,96 @@ th { color:#aaa; font-weight:normal; }
</div>
<script>
function applyFilters() {
const range=document.getElementById('dateFilter').value;
const artist=document.getElementById('artistFilter').value;
const album=document.getElementById('albumFilter').value;
const text=document.getElementById('textFilter').value.trim().toLowerCase();
const now=new Date();
const todayStart=new Date(now.getFullYear(),now.getMonth(),now.getDate()).getTime()/1000;
const tomorrow=todayStart+86400;
const yesterday=todayStart-86400;
const week=todayStart-6*86400;
const days30=todayStart-29*86400;
const monthStart=new Date(now.getFullYear(),now.getMonth(),1).getTime()/1000;
const lastMonthStart=new Date(now.getFullYear(),now.getMonth()-1,1).getTime()/1000;
const lastMonthEnd=monthStart;
const yearStart=new Date(now.getFullYear(),0,1).getTime()/1000;
let visible=0;
document.querySelectorAll('.finished-row').forEach(row=>{
const ts=Number(row.dataset.ts);
let dateOk=true;
if(range==='today') dateOk=ts>=todayStart;
else if(range==='yesterday') dateOk=ts>=yesterday && ts<todayStart;
else if(range==='7') dateOk=ts>=week;
else if(range==='30') dateOk=ts>=days30;
else if(range==='month') dateOk=ts>=monthStart;
else if(range==='lastmonth') dateOk=ts>=lastMonthStart && ts<lastMonthEnd;
else if(range==='year') dateOk=ts>=yearStart;
const artistOk=!artist || row.dataset.artist===artist;
const albumOk=!album || row.dataset.album===album;
const textOk=!text || row.dataset.search.includes(text);
const ok=dateOk && artistOk && albumOk && textOk;
row.style.display=ok?'':'none';
if(ok) visible++;
});
const total=document.querySelectorAll('.finished-row').length;
document.getElementById('filterInfo').textContent=`${visible} von ${total} Aufnahmen`;
}
['dateFilter','artistFilter','albumFilter','textFilter'].forEach(id=>document.getElementById(id)?.addEventListener('input',applyFilters));
applyFilters();
// File changes are pushed by the editor's filesystem watcher. No periodic
// page reload is performed. The current filters are preserved.
async function refreshFileLists() {
const activePlayers = Array.from(document.querySelectorAll('audio')).some(player =>
!player.paused || player.currentTime > 0
);
if (activePlayers) return;
const oldValues = {
date: document.getElementById('dateFilter')?.value || 'all',
artist: document.getElementById('artistFilter')?.value || '',
album: document.getElementById('albumFilter')?.value || '',
text: document.getElementById('textFilter')?.value || ''
};
try {
const response = await fetch('/', {cache:'no-store'});
if (!response.ok) return;
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const currentSections = document.querySelectorAll('.section');
const newSections = doc.querySelectorAll('.section');
if (currentSections.length < 2 || newSections.length < 2) return;
currentSections[0].replaceWith(newSections[0]);
currentSections[1].replaceWith(newSections[1]);
const date = document.getElementById('dateFilter');
const artist = document.getElementById('artistFilter');
const album = document.getElementById('albumFilter');
const text = document.getElementById('textFilter');
if (date) date.value = oldValues.date;
if (artist) artist.value = oldValues.artist;
if (album) album.value = oldValues.album;
if (text) text.value = oldValues.text;
['dateFilter','artistFilter','albumFilter','textFilter'].forEach(id =>
document.getElementById(id)?.addEventListener('input', applyFilters)
);
applyFilters();
} catch (error) {
console.warn('Dateiliste konnte nicht aktualisiert werden:', error);
}
}
const events = new EventSource('/api/events');
events.onmessage = () => refreshFileLists();
events.onerror = () => { /* EventSource reconnects automatically. */ };
let finishedEditOriginalFilename = null;
function toDatetimeLocal(value) {
const m = String(value || '').match(/^(\d{2})\.(\d{2})\.(\d{4}) (\d{2}):(\d{2}):(\d{2})$/);
const m = String(value || '').match(/^(\\d{2})\\.(\\d{2})\\.(\\d{4}) (\\d{2}):(\\d{2}):(\\d{2})$/);
return m ? `${m[3]}-${m[2]}-${m[1]}T${m[4]}:${m[5]}` : '';
}
@@ -1185,21 +1399,39 @@ def index():
"size": format_size(stat.st_size),
"duration": format_duration(get_duration(path)),
"sort_date": tags.get("date", "") or datetime.fromtimestamp(stat.st_mtime, ZoneInfo("Europe/Berlin")).isoformat(),
"sort_ts": metadata_timestamp(path, tags),
})
except FileNotFoundError:
pass
recordings.sort(key=lambda x: x["mtime"], reverse=True)
finished.sort(key=lambda x: x["sort_date"], reverse=True)
artists = sorted({x["artist"] for x in finished if x["artist"]}, key=str.casefold)
albums = sorted({x["album"] for x in finished if x["album"]}, key=str.casefold)
return render_template_string(
INDEX_HTML,
recordings=recordings,
finished=finished,
artists=artists,
albums=albums,
recordings_dir=str(RECORDINGS_DIR),
)
@app.route("/api/events")
def api_events():
return Response(
stream_with_context(sse_stream()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@app.route("/edit/<path:filename>")
def edit_recording(filename):
path = source_path(filename)
@@ -1360,7 +1592,10 @@ def api_edit_status(job_id):
return jsonify({"success": True, **result})
if __name__ == "__main__":
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
start_file_observer()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=WEB_PORT)