Suchfunktion eingebaut
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user