indexdatei eingebaut

This commit is contained in:
2026-09-29 23:20:19 +02:00
parent ea76ff9a4f
commit 3a94fe829b
+239 -46
View File
@@ -20,6 +20,7 @@ from watchdog.observers import Observer
RECORDINGS_DIR = Path(os.environ.get("RECORDINGS_DIR", "/recordings"))
FINISHED_DIR = Path(os.environ.get("FINISHED_DIR", "/finished"))
INDEX_FILE = Path(os.environ.get("INDEX_FILE", str(FINISHED_DIR / ".event-audio-index.json")))
WAVEFORMS_DIR = Path(os.environ.get("WAVEFORMS_DIR", "/waveforms"))
WEB_PORT = int(os.environ.get("WEB_PORT", "8080"))
@@ -550,42 +551,227 @@ def sse_stream():
SSE_CLIENTS.discard(client_queue)
class RecordingFileEventHandler(FileSystemEventHandler):
"""Turn recording/finished filesystem changes into browser events.
# ---------------------------------------------------------------------------
# Persistent file index
# ---------------------------------------------------------------------------
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.
INDEX_LOCK = threading.RLock()
FILE_INDEX = {"recordings": {}, "finished": {}}
def _index_key(path):
return path.name
def _index_section(kind):
return FILE_INDEX["recordings" if kind == "recording" else "finished"]
def _entry_from_path(path, kind, analyze=False, existing=None):
"""Create/update one index entry. FFprobe is only used when analyze=True."""
try:
stat = path.stat()
except OSError:
return None
entry = dict(existing or {})
entry.update({
"filename": path.name,
"size": stat.st_size,
"mtime": stat.st_mtime,
"kind": kind,
})
if analyze:
if kind == "finished":
tags = read_metadata(path)
entry.update({
"date": tags.get("date", ""),
"artist": tags.get("artist", ""),
"title": tags.get("title", ""),
"album": tags.get("album", ""),
})
entry["sort_ts"] = metadata_timestamp(path, tags)
else:
entry.update({
"date": "",
"artist": "",
"title": "",
"album": "",
})
entry["duration"] = get_duration(path)
entry["analyzed_mtime"] = stat.st_mtime
entry["analyzed_size"] = stat.st_size
return entry
def _write_index_locked():
"""Atomically persist the in-memory index."""
INDEX_FILE.parent.mkdir(parents=True, exist_ok=True)
temp = INDEX_FILE.with_name(f".{INDEX_FILE.name}.{uuid.uuid4().hex}.tmp")
temp.write_text(json.dumps(FILE_INDEX, ensure_ascii=False, indent=2), encoding="utf-8")
temp.replace(INDEX_FILE)
def load_file_index():
"""Load the index and reconcile it with the filesystem.
Reconciliation only stats existing files. FFprobe is run for a file when
there is no cached entry or its size/mtime changed since the last analysis.
"""
global FILE_INDEX
with INDEX_LOCK:
try:
data = json.loads(INDEX_FILE.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("index root is not an object")
FILE_INDEX = {
"recordings": data.get("recordings", {}) if isinstance(data.get("recordings", {}), dict) else {},
"finished": data.get("finished", {}) if isinstance(data.get("finished", {}), dict) else {},
}
except (OSError, ValueError, json.JSONDecodeError):
FILE_INDEX = {"recordings": {}, "finished": {}}
changed = False
for kind, directory in (("recording", RECORDINGS_DIR), ("finished", FINISHED_DIR)):
section = _index_section(kind)
existing_names = set(section)
current_names = set()
for path in directory.glob("*.mp3"):
current_names.add(path.name)
try:
stat = path.stat()
except OSError:
continue
entry = section.get(path.name)
needs_analysis = (
not isinstance(entry, dict)
or entry.get("analyzed_mtime") != stat.st_mtime
or entry.get("analyzed_size") != stat.st_size
or "duration" not in entry
)
new_entry = _entry_from_path(path, kind, analyze=needs_analysis, existing=entry)
if new_entry is not None and new_entry != entry:
section[path.name] = new_entry
changed = True
for name in existing_names - current_names:
section.pop(name, None)
changed = True
if changed or not INDEX_FILE.exists():
_write_index_locked()
print(
f"[index] loaded {len(FILE_INDEX['recordings'])} recordings, "
f"{len(FILE_INDEX['finished'])} finished files",
flush=True,
)
def index_file_created(path, kind):
"""Add a newly created MP3 to the index. Finished files are already stable
when created atomically (uploads/finished outputs), while recordings may
still be written and are analyzed only on close_write.
"""
with INDEX_LOCK:
entry = _entry_from_path(path, kind, analyze=(kind == "finished"))
if entry is None:
return
entry.setdefault("duration", None)
entry.setdefault("date", "")
entry.setdefault("artist", "")
entry.setdefault("title", "")
entry.setdefault("album", "")
_index_section(kind)[path.name] = entry
_write_index_locked()
broadcast_event("files_changed", {"action": "created", "filename": path.name})
def index_file_closed(path, kind):
"""Analyze a file once it is closed and therefore stable."""
with INDEX_LOCK:
if not path.is_file():
return
entry = _entry_from_path(path, kind, analyze=True, existing=_index_section(kind).get(path.name))
if entry is None:
return
_index_section(kind)[path.name] = entry
_write_index_locked()
broadcast_event("files_changed", {"action": "closed", "filename": path.name})
def index_file_deleted(path, kind):
with INDEX_LOCK:
_index_section(kind).pop(path.name, None)
_write_index_locked()
broadcast_event("files_changed", {"action": "deleted", "filename": path.name})
def index_file_moved(src_path, dest_path, kind):
with INDEX_LOCK:
section = _index_section(kind)
entry = section.pop(src_path.name, None)
if dest_path.is_file():
entry = _entry_from_path(dest_path, kind, analyze=entry is None, existing=entry)
if entry is not None:
section[dest_path.name] = entry
_write_index_locked()
broadcast_event("files_changed", {"action": "moved", "filename": dest_path.name})
class RecordingFileEventHandler(FileSystemEventHandler):
"""Keep the persistent file index in sync and notify connected browsers."""
def _kind_for_path(self, path):
try:
resolved = Path(path).resolve()
if resolved.parent == RECORDINGS_DIR.resolve():
return "recording"
if resolved.parent == FINISHED_DIR.resolve():
return "finished"
except OSError:
pass
return None
def _is_relevant(self, path):
path = Path(path)
if path.name.startswith("."):
return False
return path.suffix.lower() == ".mp3"
return not path.name.startswith(".") and 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})
kind = self._kind_for_path(event.src_path)
if kind:
index_file_created(Path(event.src_path), kind)
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})
kind = self._kind_for_path(event.src_path)
if kind:
index_file_deleted(Path(event.src_path), kind)
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})
src = Path(event.src_path)
dest = Path(event.dest_path)
if not self._is_relevant(src) and not self._is_relevant(dest):
return
kind = self._kind_for_path(dest) or self._kind_for_path(src)
if kind:
index_file_moved(src, dest, kind)
def on_closed(self, event):
# watchdog's Linux/inotify backend emits this when FFmpeg closes the
# recording. That is the useful "recording finished" signal.
# Linux/inotify emits close_write when FFmpeg has finished writing.
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})
kind = self._kind_for_path(event.src_path)
if kind:
index_file_closed(Path(event.src_path), kind)
def start_file_observer():
@@ -594,6 +780,8 @@ def start_file_observer():
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
load_file_index()
observer = Observer()
handler = RecordingFileEventHandler()
observer.schedule(handler, str(RECORDINGS_DIR), recursive=False)
@@ -1442,41 +1630,46 @@ async function deleteFile(kind, filename) {
@app.route("/")
def index():
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
with INDEX_LOCK:
recording_entries = [dict(entry) for entry in FILE_INDEX["recordings"].values()]
finished_entries = [dict(entry) for entry in FILE_INDEX["finished"].values()]
recordings = []
for path in RECORDINGS_DIR.glob("*.mp3"):
try:
stat = path.stat()
recordings.append({
"filename": path.name,
"datetime": format_datetime(stat.st_mtime),
"size": format_size(stat.st_size),
"duration": format_duration(get_duration(path)),
"mtime": stat.st_mtime,
})
except FileNotFoundError:
pass
for item in recording_entries:
recordings.append({
"filename": item["filename"],
"datetime": format_datetime(item.get("mtime", time.time())),
"size": format_size(item.get("size", 0)),
"duration": format_duration(item.get("duration")),
"mtime": item.get("mtime", 0),
})
finished = []
for path in FINISHED_DIR.glob("*.mp3"):
try:
stat = path.stat()
tags = read_metadata(path)
finished.append({
"filename": path.name,
"datetime": metadata_datetime(path),
"artist": tags.get("artist", ""),
"title": tags.get("title", ""),
"album": tags.get("album", ""),
"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
for item in finished_entries:
date = item.get("date", "")
sort_ts = item.get("sort_ts")
if sort_ts is None:
sort_ts = item.get("mtime", 0)
dt_text = format_datetime(sort_ts)
if date:
try:
dt = datetime.fromisoformat(date.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("Europe/Berlin"))
dt_text = dt.astimezone(ZoneInfo("Europe/Berlin")).strftime("%d.%m.%Y %H:%M:%S")
except ValueError:
pass
finished.append({
"filename": item["filename"],
"datetime": dt_text,
"artist": item.get("artist", ""),
"title": item.get("title", ""),
"album": item.get("album", ""),
"size": format_size(item.get("size", 0)),
"duration": format_duration(item.get("duration")),
"sort_date": date or datetime.fromtimestamp(item.get("mtime", 0), ZoneInfo("Europe/Berlin")).isoformat(),
"sort_ts": sort_ts,
})
recordings.sort(key=lambda x: x["mtime"], reverse=True)
finished.sort(key=lambda x: x["sort_date"], reverse=True)