indexdatei eingebaut
This commit is contained in:
@@ -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()
|
||||
for item in recording_entries:
|
||||
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,
|
||||
"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),
|
||||
})
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
finished = []
|
||||
for path in FINISHED_DIR.glob("*.mp3"):
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user