Suchfunktion eingebaut
This commit is contained in:
@@ -8,7 +8,10 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from zoneinfo import ZoneInfo
|
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 = {}
|
||||||
JOBS_LOCK = threading.Lock()
|
JOBS_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
SSE_CLIENTS = set()
|
||||||
|
SSE_LOCK = threading.Lock()
|
||||||
|
FILE_OBSERVER = None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Application
|
# Application
|
||||||
@@ -355,6 +362,20 @@ def metadata_datetime(path):
|
|||||||
return format_datetime(path.stat().st_mtime)
|
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):
|
def metadata_form_value(path):
|
||||||
tags = read_metadata(path)
|
tags = read_metadata(path)
|
||||||
value = tags.get("date", "")
|
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())
|
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
|
# HTML
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -954,6 +1065,11 @@ body { margin:0; padding:24px; background:#111; color:#eee; font-family:Arial,He
|
|||||||
h1 { margin:0 0 6px; }
|
h1 { margin:0 0 6px; }
|
||||||
.subtitle { color:#aaa; margin-bottom:22px; }
|
.subtitle { color:#aaa; margin-bottom:22px; }
|
||||||
.section { background:#181818; border:1px solid #333; border-radius:10px; padding:18px; margin-bottom:18px; }
|
.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; }
|
table { width:100%; border-collapse:collapse; }
|
||||||
th,td { padding:10px 8px; border-bottom:1px solid #333; text-align:left; }
|
th,td { padding:10px 8px; border-bottom:1px solid #333; text-align:left; }
|
||||||
th { color:#aaa; font-weight:normal; }
|
th { color:#aaa; font-weight:normal; }
|
||||||
@@ -972,6 +1088,7 @@ th { color:#aaa; font-weight:normal; }
|
|||||||
.loading-overlay.active { display:flex; }
|
.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; }
|
.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); } }
|
@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; } }
|
@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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -1006,12 +1123,23 @@ th { color:#aaa; font-weight:normal; }
|
|||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Bearbeitete Aufnahmen</h2>
|
<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 %}
|
{% 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>
|
<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 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.datetime }}</td>
|
||||||
<td>{{ item.artist or "–" }}</td>
|
<td>{{ item.artist or "–" }}</td>
|
||||||
<td>{{ item.title or "–" }}</td>
|
<td>{{ item.title or "–" }}</td>
|
||||||
@@ -1045,10 +1173,96 @@ th { color:#aaa; font-weight:normal; }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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;
|
let finishedEditOriginalFilename = null;
|
||||||
|
|
||||||
function toDatetimeLocal(value) {
|
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]}` : '';
|
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),
|
"size": format_size(stat.st_size),
|
||||||
"duration": format_duration(get_duration(path)),
|
"duration": format_duration(get_duration(path)),
|
||||||
"sort_date": tags.get("date", "") or datetime.fromtimestamp(stat.st_mtime, ZoneInfo("Europe/Berlin")).isoformat(),
|
"sort_date": tags.get("date", "") or datetime.fromtimestamp(stat.st_mtime, ZoneInfo("Europe/Berlin")).isoformat(),
|
||||||
|
"sort_ts": metadata_timestamp(path, tags),
|
||||||
})
|
})
|
||||||
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["sort_date"], 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(
|
return render_template_string(
|
||||||
INDEX_HTML,
|
INDEX_HTML,
|
||||||
recordings=recordings,
|
recordings=recordings,
|
||||||
finished=finished,
|
finished=finished,
|
||||||
|
artists=artists,
|
||||||
|
albums=albums,
|
||||||
recordings_dir=str(RECORDINGS_DIR),
|
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>")
|
@app.route("/edit/<path:filename>")
|
||||||
def edit_recording(filename):
|
def edit_recording(filename):
|
||||||
path = source_path(filename)
|
path = source_path(filename)
|
||||||
@@ -1360,7 +1592,10 @@ def api_edit_status(job_id):
|
|||||||
return jsonify({"success": True, **result})
|
return jsonify({"success": True, **result})
|
||||||
|
|
||||||
|
|
||||||
|
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
start_file_observer()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
app.run(host="0.0.0.0", port=WEB_PORT)
|
app.run(host="0.0.0.0", port=WEB_PORT)
|
||||||
|
|||||||
Reference in New Issue
Block a user