diff --git a/editor.py b/editor.py index d8aa13d..43a68d7 100644 --- a/editor.py +++ b/editor.py @@ -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)