diff --git a/mediahive/__main__.py b/mediahive/__main__.py index 4006f32..fed0f37 100644 --- a/mediahive/__main__.py +++ b/mediahive/__main__.py @@ -67,7 +67,7 @@ def main() -> None: if use_gui: try: - from mediahive.winmain import winmain + from mediahive.winmain import gui_main except ImportError as exc: if args.gui: raise RuntimeError( @@ -75,7 +75,7 @@ def main() -> None: "Install with: uv pip install mediahive[gui]" ) from exc else: - winmain() + gui_main() return if args.media_folders: diff --git a/mediahive/hivescan/scanner.py b/mediahive/hivescan/scanner.py index b882b66..256cf11 100644 --- a/mediahive/hivescan/scanner.py +++ b/mediahive/hivescan/scanner.py @@ -140,15 +140,21 @@ class RootScanner: if task and not task.done(): task.cancel() # Wait briefly for graceful shutdown to avoid lingering scanner tasks. - for task in tasks: - if task and not task.done(): - with contextlib.suppress(TimeoutError, asyncio.CancelledError): - await asyncio.wait_for(task, timeout=2.0) - # Best-effort persistence of scanner state. + # All tasks are awaited concurrently (a slow one must not delay the + # others), under a single overall timeout. + pending = [task for task in tasks if task and not task.done()] + if pending: + with contextlib.suppress(TimeoutError, asyncio.CancelledError): + 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.to_thread(self._save_scan_state) - await asyncio.to_thread(self._save_reel_state) - await asyncio.to_thread(save_probe_records) + await asyncio.gather( + asyncio.to_thread(self._save_scan_state), + asyncio.to_thread(self._save_reel_state), + asyncio.to_thread(save_probe_records), + ) def is_scanning(self) -> bool: return self._scan_task is not None and not self._scan_task.done() diff --git a/mediahive/root_registry.py b/mediahive/root_registry.py index 10dccc8..d3a91ff 100644 --- a/mediahive/root_registry.py +++ b/mediahive/root_registry.py @@ -353,6 +353,10 @@ class Supervisor: async def shutdown(self) -> None: async with self._lock: - for ctx in list(self._contexts.values()): - await ctx.stop() + # Stop roots concurrently — each may wait on task cancellation and + # network-mount snapshot flushes, and those delays must not add up. + await asyncio.gather( + *(ctx.stop() for ctx in list(self._contexts.values())), + return_exceptions=True, + ) self._contexts.clear() diff --git a/mediahive/winmain.py b/mediahive/winmain.py index b564d16..f2b80f5 100644 --- a/mediahive/winmain.py +++ b/mediahive/winmain.py @@ -688,6 +688,46 @@ def _start_gamepad_remote( return thread +def _rotate_and_open_log(log_path: Path): + """Rotate mediahive.log to .log.1 and open a fresh log file. + + Raises OSError when a previous MediaHive instance still holds the file + open (Windows forbids renaming a file that is open without delete + sharing) — callers treat that as "previous instance not dead yet". + """ + prev = log_path.with_suffix(".log.1") + if log_path.exists(): + if prev.exists(): + prev.unlink() + log_path.rename(prev) + fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC) + return os.fdopen(fd, "w", encoding="utf-8", buffering=1) # line-buffered + + +def _wait_for_previous_instance(log_path: Path, timeout: float = 15.0): + """Show a waiting notice while a previous MediaHive instance exits. + + Returns an open log file handle, or None on timeout. + """ + result: list = [] + window = webview.create_window( + "MediaHive", html=_WAIT_HTML, width=520, height=280, resizable=False + ) + + def poll() -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + result.append(_rotate_and_open_log(log_path)) + break + except OSError: + time.sleep(0.5) + window.destroy() + + webview.start(func=poll, icon=_icon_path(), **_webview_start_kwargs()) + return result[0] if result else None + + def _setup_logging() -> Path: """Redirect stdout/stderr and configure logging to a file in %APPDATA%/mediahive/. @@ -702,19 +742,30 @@ def _setup_logging() -> Path: log_dir.mkdir(parents=True, exist_ok=True) log_path = log_dir / "mediahive.log" - # Rotate: keep previous run as .log.1 - prev = log_path.with_suffix(".log.1") - if log_path.exists(): - if prev.exists(): - prev.unlink() - log_path.rename(prev) + try: + log_file = _rotate_and_open_log(log_path) + except OSError: + # A previous instance still holds the log file. It is usually on its + # way out — give it a couple of seconds silently first. + log_file = None + deadline = time.monotonic() + 2.0 + while log_file is None and time.monotonic() < deadline: + time.sleep(0.25) + with contextlib.suppress(OSError): + log_file = _rotate_and_open_log(log_path) + if log_file is None: + log_file = _wait_for_previous_instance(log_path) + if log_file is None: + # Never fail startup over logging: fall back to a per-process file. + log_path = log_dir / f"mediahive-{os.getpid()}.log" + with contextlib.suppress(OSError): + fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC) + log_file = os.fdopen(fd, "w", encoding="utf-8", buffering=1) - fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC) - log_file = os.fdopen(fd, "w", encoding="utf-8", buffering=1) # line-buffered - - # Redirect raw stdout/stderr so print() and tracebacks go to the file - sys.stdout = log_file - sys.stderr = log_file + if log_file is not None: + # Redirect raw stdout/stderr so print() and tracebacks go to the file + sys.stdout = log_file + sys.stderr = log_file # force=True removes handlers added by uvicorn/fastapi during import so that # basicConfig actually takes effect (without it, it's a silent no-op) @@ -743,6 +794,54 @@ _SETUP_HTML = """
Choose a folder that contains your media…