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"))
|
RECORDINGS_DIR = Path(os.environ.get("RECORDINGS_DIR", "/recordings"))
|
||||||
FINISHED_DIR = Path(os.environ.get("FINISHED_DIR", "/finished"))
|
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"))
|
WAVEFORMS_DIR = Path(os.environ.get("WAVEFORMS_DIR", "/waveforms"))
|
||||||
WEB_PORT = int(os.environ.get("WEB_PORT", "8080"))
|
WEB_PORT = int(os.environ.get("WEB_PORT", "8080"))
|
||||||
|
|
||||||
@@ -550,42 +551,227 @@ def sse_stream():
|
|||||||
SSE_CLIENTS.discard(client_queue)
|
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,
|
INDEX_LOCK = threading.RLock()
|
||||||
continuous writes to an active recording are deliberately ignored so the
|
FILE_INDEX = {"recordings": {}, "finished": {}}
|
||||||
browser is not flooded while FFmpeg is recording.
|
|
||||||
|
|
||||||
|
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):
|
def _is_relevant(self, path):
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
if path.name.startswith("."):
|
return not path.name.startswith(".") and path.suffix.lower() == ".mp3"
|
||||||
return False
|
|
||||||
return path.suffix.lower() == ".mp3"
|
|
||||||
|
|
||||||
def on_created(self, event):
|
def on_created(self, event):
|
||||||
if event.is_directory or not self._is_relevant(event.src_path):
|
if event.is_directory or not self._is_relevant(event.src_path):
|
||||||
return
|
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):
|
def on_deleted(self, event):
|
||||||
if event.is_directory or not self._is_relevant(event.src_path):
|
if event.is_directory or not self._is_relevant(event.src_path):
|
||||||
return
|
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):
|
def on_moved(self, event):
|
||||||
if event.is_directory:
|
if event.is_directory:
|
||||||
return
|
return
|
||||||
if self._is_relevant(event.src_path) or self._is_relevant(event.dest_path):
|
src = Path(event.src_path)
|
||||||
broadcast_event("files_changed", {"action": "moved", "filename": Path(event.dest_path).name})
|
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):
|
def on_closed(self, event):
|
||||||
# watchdog's Linux/inotify backend emits this when FFmpeg closes the
|
# Linux/inotify emits close_write when FFmpeg has finished writing.
|
||||||
# recording. That is the useful "recording finished" signal.
|
|
||||||
if event.is_directory or not self._is_relevant(event.src_path):
|
if event.is_directory or not self._is_relevant(event.src_path):
|
||||||
return
|
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():
|
def start_file_observer():
|
||||||
@@ -594,6 +780,8 @@ def start_file_observer():
|
|||||||
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
|
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
load_file_index()
|
||||||
|
|
||||||
observer = Observer()
|
observer = Observer()
|
||||||
handler = RecordingFileEventHandler()
|
handler = RecordingFileEventHandler()
|
||||||
observer.schedule(handler, str(RECORDINGS_DIR), recursive=False)
|
observer.schedule(handler, str(RECORDINGS_DIR), recursive=False)
|
||||||
@@ -1442,41 +1630,46 @@ async function deleteFile(kind, filename) {
|
|||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
with INDEX_LOCK:
|
||||||
FINISHED_DIR.mkdir(parents=True, exist_ok=True)
|
recording_entries = [dict(entry) for entry in FILE_INDEX["recordings"].values()]
|
||||||
|
finished_entries = [dict(entry) for entry in FILE_INDEX["finished"].values()]
|
||||||
|
|
||||||
recordings = []
|
recordings = []
|
||||||
for path in RECORDINGS_DIR.glob("*.mp3"):
|
for item in recording_entries:
|
||||||
try:
|
|
||||||
stat = path.stat()
|
|
||||||
recordings.append({
|
recordings.append({
|
||||||
"filename": path.name,
|
"filename": item["filename"],
|
||||||
"datetime": format_datetime(stat.st_mtime),
|
"datetime": format_datetime(item.get("mtime", time.time())),
|
||||||
"size": format_size(stat.st_size),
|
"size": format_size(item.get("size", 0)),
|
||||||
"duration": format_duration(get_duration(path)),
|
"duration": format_duration(item.get("duration")),
|
||||||
"mtime": stat.st_mtime,
|
"mtime": item.get("mtime", 0),
|
||||||
})
|
})
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
finished = []
|
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:
|
try:
|
||||||
stat = path.stat()
|
dt = datetime.fromisoformat(date.replace("Z", "+00:00"))
|
||||||
tags = read_metadata(path)
|
if dt.tzinfo is None:
|
||||||
finished.append({
|
dt = dt.replace(tzinfo=ZoneInfo("Europe/Berlin"))
|
||||||
"filename": path.name,
|
dt_text = dt.astimezone(ZoneInfo("Europe/Berlin")).strftime("%d.%m.%Y %H:%M:%S")
|
||||||
"datetime": metadata_datetime(path),
|
except ValueError:
|
||||||
"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
|
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)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user