diff --git a/editor.py b/editor.py index 4fdd795..6b87ceb 100644 --- a/editor.py +++ b/editor.py @@ -8,7 +8,10 @@ from datetime import datetime from pathlib import Path 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_LOCK = threading.Lock() +SSE_CLIENTS = set() +SSE_LOCK = threading.Lock() +FILE_OBSERVER = None + # --------------------------------------------------------------------------- # Application @@ -355,6 +362,20 @@ def metadata_datetime(path): 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): tags = read_metadata(path) 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()) +# --------------------------------------------------------------------------- +# 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 # --------------------------------------------------------------------------- @@ -954,6 +1065,11 @@ body { margin:0; padding:24px; background:#111; color:#eee; font-family:Arial,He h1 { margin:0 0 6px; } .subtitle { color:#aaa; margin-bottom:22px; } .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; } th,td { padding:10px 8px; border-bottom:1px solid #333; text-align:left; } th { color:#aaa; font-weight:normal; } @@ -972,6 +1088,7 @@ th { color:#aaa; font-weight:normal; } .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; } @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; } } @@ -1006,12 +1123,23 @@ th { color:#aaa; font-weight:normal; }
| Zeitpunkt | Redner | Beschreibung | Veranstaltung | Dauer | |
|---|---|---|---|---|---|
| {{ item.datetime }} | {{ item.artist or "–" }} | {{ item.title or "–" }} | @@ -1045,10 +1173,96 @@ th { color:#aaa; font-weight:normal; }