diff --git a/mediahive/__main__.py b/mediahive/__main__.py index fed0f37..fdf16ed 100644 --- a/mediahive/__main__.py +++ b/mediahive/__main__.py @@ -33,6 +33,54 @@ def _derive_name(path: str) -> str: return p.name or p.anchor.strip("/\\").lower() or "media" +def _dev_reload_supervisor() -> None: + """Windows dev-mode reloader: restart the server process on changes. + + uvicorn's own reload cannot work here: it restarts the child with + CTRL_C_EVENT, which is never delivered to a plain spawn child (no own + console process group), so the reloader blocks in join() after the + first reload and the old server — scanner included — keeps running. + And even when the child does restart, uvicorn passes it sockets bound + by the parent; ProactorEventLoop cannot register inherited sockets + with IOCP (WinError 87 on accept), while the selector loop would lose + asyncio subprocess support (ffmpeg/ffprobe showreel generation). + + So: watch the package directory ourselves and respawn a fresh child + process that binds its own sockets. The child runs with + MEDIAHIVE_DEV_CHILD=1 and reload disabled. Scanner state is persisted + after every scan, so a non-graceful child exit on reload loses nothing. + """ + import subprocess + + import watchfiles + + watch_dir = Path(__file__).parent + argv = [sys.executable, "-m", "mediahive", *sys.argv[1:]] + child_env = dict(os.environ, MEDIAHIVE_DEV_CHILD="1") + + print(f"Dev reloader: watching {watch_dir}", file=sys.stderr) + proc = subprocess.Popen(argv, env=child_env) + try: + for changes in watchfiles.watch(watch_dir): + changed = sorted({str(Path(p).name) for _, p in changes}) + print( + f"Dev reloader: change in {', '.join(changed[:5])} — restarting", + file=sys.stderr, + ) + proc.terminate() + proc.wait() + proc = subprocess.Popen(argv, env=child_env) + except KeyboardInterrupt: + pass + finally: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + def main() -> None: _configure_windows_event_loop_policy() @@ -94,6 +142,14 @@ def main() -> None: roots[name] = p.as_posix() os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots) + if ( + DEVMODE + and sys.platform == "win32" + and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1" + ): + _dev_reload_supervisor() + return + server.run( "mediahive.server:app", listen=args.listen, diff --git a/mediahive/hivescan/scanner.py b/mediahive/hivescan/scanner.py index 7d3b0e6..bfc02bd 100644 --- a/mediahive/hivescan/scanner.py +++ b/mediahive/hivescan/scanner.py @@ -163,13 +163,10 @@ class RootScanner: await asyncio.wait_for( asyncio.gather(*pending, return_exceptions=True), timeout=2.0 ) - # Best-effort persistence of scanner state, concurrently. - with contextlib.suppress(Exception): - await asyncio.gather( - asyncio.to_thread(self._save_scan_state), - asyncio.to_thread(self._save_reel_state), - asyncio.to_thread(save_probe_records), - ) + # No final state save on shutdown: state is persisted after every + # completed scan, and a last-minute save over a network drive only + # delays process exit (executor threads block interpreter shutdown, + # so log lines appear after the shell prompt has returned). def is_scanning(self) -> bool: return self._scan_task is not None and not self._scan_task.done() @@ -1115,7 +1112,7 @@ class RootScanner: ) ) ) - logger.info("Scan cancelled (%s)", task_id) + logger.debug("Scan cancelled (%s)", task_id) except Exception: logger.exception("Scan failed (%s)", task_id) await self._send( @@ -1343,7 +1340,7 @@ class RootScanner: await asyncio.to_thread(self._save_reel_state) except asyncio.CancelledError: - logger.info("Showreel worker shutting down for root %s", self.root_id) + logger.debug("Showreel worker shutting down for root %s", self.root_id) return except Exception: logger.exception(