diff --git a/mediahive/assets/mediahive-logo.png b/mediahive/assets/mediahive-logo.png deleted file mode 100644 index d7fdfa0..0000000 Binary files a/mediahive/assets/mediahive-logo.png and /dev/null differ diff --git a/mediahive/server.py b/mediahive/server.py index b218d1d..c397be4 100644 --- a/mediahive/server.py +++ b/mediahive/server.py @@ -14,16 +14,12 @@ import os import re import subprocess import sys +import tempfile import urllib.error import urllib.request from contextlib import asynccontextmanager from pathlib import Path -# Suppress console windows when spawning subprocesses on Windows -_POPEN_KWARGS: dict = ( - {"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {} -) - import aiofiles import msgspec import msgspec.structs @@ -50,24 +46,40 @@ logger = logging.getLogger("mediahive.server") MPC_BE_BASE_URL = "http://127.0.0.1:13579" +# Suppress console windows when spawning subprocesses on Windows +_POPEN_KWARGS: dict = ( + {"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {} +) + # Vue Frontend static files frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"]) # Media root path (initialized in lifespan) MEDIAROOT = None -# In-memory index store (initialized in lifespan) -store: IndexStore | None = None +# In-memory index store (available immediately, switched to real root later) +_BOOTSTRAP_SNAPSHOT = Path(tempfile.gettempdir()) / "mediahive" / "index.json" +store: IndexStore = IndexStore(_BOOTSTRAP_SNAPSHOT, media_root=None) # Whether the scanner subsystem is active _scanner_active = False +# Background folder switch task and lock so startup/change-folder cannot race +_folder_switch_task: asyncio.Task | None = None +_folder_switch_lock = asyncio.Lock() + # Queue for scanner → server events _scan_events: asyncio.Queue[ScanEvent] = asyncio.Queue() _consumer_task: asyncio.Task | None = None _RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$") +def _require_media_root() -> Path: + if MEDIAROOT is None: + raise HTTPException(status_code=503, detail="Media root not initialized yet") + return MEDIAROOT + + async def _send_event(event: ScanEvent) -> None: """Push a scan event onto the queue (passed to hivescan as *send*).""" await _scan_events.put(event) @@ -135,42 +147,39 @@ async def _consume_scan_events() -> None: @asynccontextmanager async def lifespan(app: FastAPI): - global MEDIAROOT, store, _scanner_active, _consumer_task + global MEDIAROOT, store, _scanner_active, _consumer_task, _folder_switch_task - if not os.environ.get("MEDIAHIVE_PATH"): - raise RuntimeError("MEDIAHIVE_PATH environment variable must be set") - - MEDIAROOT = Path(os.environ["MEDIAHIVE_PATH"]) await frontend.load() - # Initialise the in-memory index store - snapshot_path = MEDIAROOT / ".mediahive" / "index.json" - store = IndexStore(snapshot_path, media_root=str(MEDIAROOT)) + # Bring up the API immediately with an empty in-memory store. + # Media-root initialization/scanner startup are deferred to a background task + # so macOS permission prompts cannot block server readiness. + store = IndexStore(_BOOTSTRAP_SNAPSHOT, media_root=None) await store.load_snapshot() + _scanner_active = False + logger.info( - "Index store ready: %d movies, %d series", - len(store.movies), - len(store.series), + "Server started without active media root; waiting for folder activation" ) - # Start the scanner subsystem - from mediahive.hivescan.scanner import ( - start as start_scanner, - ) - from mediahive.hivescan.scanner import ( - stop as stop_scanner, - ) - - _consumer_task = asyncio.create_task(_consume_scan_events()) - await start_scanner(_send_event) - _scanner_active = True - yield # Shutdown + if _folder_switch_task and not _folder_switch_task.done(): + _folder_switch_task.cancel() + try: + await _folder_switch_task + except asyncio.CancelledError: + pass + await stop_scanner() + _scanner_active = False if _consumer_task: _consumer_task.cancel() + try: + await _consumer_task + except asyncio.CancelledError: + pass await store.flush_snapshot() @@ -193,14 +202,11 @@ def normalize_path(url_path: str) -> Path: Returns: MEDIAROOT/.mediahive/Movies/... """ clean_path = url_path.lstrip("/") - return MEDIAROOT / clean_path + return _require_media_root() / clean_path def _load_resume_positions() -> dict[str, int]: - if MEDIAROOT is None: - return {} - - playback_state_path = MEDIAROOT / ".mediahive" / "playback-state.json" + playback_state_path = _require_media_root() / ".mediahive" / "playback-state.json" try: raw = json.loads(playback_state_path.read_text(encoding="utf-8")) except Exception: @@ -268,43 +274,50 @@ async def change_folder_endpoint(request: Request): async def _switch_folder(new_root: Path) -> None: - global MEDIAROOT, store, _consumer_task, _scan_events + global MEDIAROOT, store, _consumer_task, _scan_events, _scanner_active - try: - # Cancel scanner tasks immediately — no need to wait 30 s - await stop_scanner() + async with _folder_switch_lock: + try: + # Cancel scanner tasks immediately — no need to wait 30 s + await stop_scanner() + _scanner_active = False - # Tear down the old event consumer - if _consumer_task and not _consumer_task.done(): - _consumer_task.cancel() - try: - await _consumer_task - except asyncio.CancelledError: - pass + # Tear down the old event consumer + if _consumer_task and not _consumer_task.done(): + _consumer_task.cancel() + try: + await _consumer_task + except asyncio.CancelledError: + pass - # Flush the old index snapshot - if store: + # Flush the old index snapshot await store.flush_snapshot() - # Update env and module globals - os.environ["MEDIAHIVE_PATH"] = str(new_root) - MEDIAROOT = new_root + # Update env and module globals + os.environ["MEDIAHIVE_PATH"] = str(new_root) + MEDIAROOT = new_root - # Fresh event queue — discard any stale events from the old folder - _scan_events = asyncio.Queue() + # Fresh event queue — discard any stale events from the old folder + _scan_events = asyncio.Queue() - # Re-initialise the index store - snapshot_path = MEDIAROOT / ".mediahive" / "index.json" - store = IndexStore(snapshot_path, media_root=str(MEDIAROOT)) - await store.load_snapshot() + # Re-initialise the index store + snapshot_path = MEDIAROOT / ".mediahive" / "index.json" + store = IndexStore(snapshot_path, media_root=str(MEDIAROOT)) + await store.load_snapshot() + logger.info( + "Index store ready: %d movies, %d series", + len(store.movies), + len(store.series), + ) - # Restart consumer and scanner - _consumer_task = asyncio.create_task(_consume_scan_events()) - await start_scanner(_send_event) + # Restart consumer and scanner + _consumer_task = asyncio.create_task(_consume_scan_events()) + await start_scanner(_send_event) + _scanner_active = True - logger.info("Switched media folder to %s", MEDIAROOT) - except Exception: - logger.exception("Error switching media folder to %s", new_root) + logger.info("Switched media folder to %s", MEDIAROOT) + except Exception: + logger.exception("Error switching media folder to %s", new_root) @app.get("/api/index") @@ -377,7 +390,7 @@ async def play_media(request: Request): """ req = msgspec.json.decode(await request.body(), type=PlayMediaRequest) print(f"[play] Received path: {req.file_path}") - file_path = MEDIAROOT / req.file_path + file_path = _require_media_root() / req.file_path if not file_path.exists(): print(f"[play] File not found: {file_path}") @@ -400,7 +413,7 @@ async def open_folder(request: Request): """ req = msgspec.json.decode(await request.body(), type=OpenFolderRequest) print(f"[open-folder] Received path: {req.folder_path}") - target_path = MEDIAROOT / req.folder_path + target_path = _require_media_root() / req.folder_path if not target_path.exists(): print(f"[open-folder] Path not found: {target_path}") @@ -466,10 +479,11 @@ async def serve_media_file(file_path: str, request: Request): Serve a media file asynchronously. """ full_path = normalize_path(file_path) + media_root = _require_media_root() # Security: ensure path doesn't escape base try: - full_path.resolve().relative_to(MEDIAROOT.resolve()) + full_path.resolve().relative_to(media_root.resolve()) except ValueError: raise HTTPException(status_code=403, detail="Access denied") diff --git a/mediahive/winmain.py b/mediahive/winmain.py index 135ff0c..9383b10 100644 --- a/mediahive/winmain.py +++ b/mediahive/winmain.py @@ -5,7 +5,6 @@ Or from PyInstaller: MediaHive.exe [media_folder] """ import argparse -import base64 import ctypes import html import json @@ -19,7 +18,6 @@ import time import urllib.error import urllib.parse import urllib.request -from collections.abc import Callable from concurrent.futures import Future, ThreadPoolExecutor from pathlib import Path @@ -27,16 +25,15 @@ import msgspec.structs import uvicorn import webview -from mediahive.__main__ import resolve_media_root from mediahive.config import load_config, save_config logger = logging.getLogger("mediahive.winmain") BACKEND_HOST = "127.0.0.1" BACKEND_PORT = 8420 +HEALTH_TIMEOUT = 2 # seconds BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds BACKEND_HEALTH_POLL_SECONDS = 0.25 -STARTUP_LOG_TAIL_LINES = 120 MPC_BE_URL = "http://127.0.0.1:13579" GAMEPAD_REPEAT_SECONDS = 0.008 GAMEPAD_POLL_SECONDS = 0.008 @@ -632,194 +629,11 @@ _SETUP_HTML = """ """ -_STARTUP_HTML_TEMPLATE = """ -MediaHive -
-
- -
-
-
Starting MediaHive
-
Waiting for startup logs...
-
- -
-
-
- -""" - - class JsApi: """Python methods exposed to the frontend via window.pywebview.api.""" def __init__(self) -> None: self._window: webview.Window | None = None - self._startup_state: dict[str, object] | None = None - self._startup_state_lock: threading.Lock | None = None - self._startup_log_path: Path | None = None - self._request_quit_callback: Callable[[], None] | None = None def pick_folder(self) -> str | None: """Open a native OS folder picker and return the chosen path (or None).""" @@ -828,46 +642,6 @@ class JsApi: result = self._window.create_file_dialog(webview.FOLDER_DIALOG) return result[0] if result else None - def configure_startup_bridge( - self, - startup_state: dict[str, object], - startup_state_lock: threading.Lock, - startup_log_path: Path | None, - request_quit_callback: Callable[[], None], - ) -> None: - self._startup_state = startup_state - self._startup_state_lock = startup_state_lock - self._startup_log_path = startup_log_path - self._request_quit_callback = request_quit_callback - - def startup_status(self) -> dict[str, object]: - if self._startup_state is None or self._startup_state_lock is None: - return { - "ready": False, - "failed": False, - "message": "Initializing startup bridge...", - "backend_url": "", - } - with self._startup_state_lock: - return dict(self._startup_state) - - def startup_log_tail(self) -> str: - if self._startup_log_path is None: - return "Log file unavailable in development mode." - try: - lines = self._startup_log_path.read_text(encoding="utf-8").splitlines() - except Exception as exc: - return f"Could not read startup log: {exc}" - if not lines: - return "No startup log entries yet." - return "\n".join(lines[-STARTUP_LOG_TAIL_LINES:]) - - def quit_app(self) -> None: - if self._request_quit_callback is not None: - self._request_quit_callback() - if self._window is not None: - self._window.destroy() - def _prepend_meipass_to_path() -> None: """When frozen, ensure bundled binaries (ffmpeg) are found first on PATH.""" @@ -900,53 +674,6 @@ def _icon_path() -> str | None: return str(ico) if ico.exists() else None -def _runtime_package_base() -> Path: - """Return runtime package base for frozen and development layouts.""" - if getattr(sys, "frozen", False): - meipass = Path(sys._MEIPASS) # type: ignore[attr-defined] - packaged = meipass / "mediahive" - return packaged if packaged.exists() else meipass - return Path(__file__).parent - - -def _startup_logo_path() -> Path | None: - """Locate the startup logo asset for embedding in the splash page.""" - base = _runtime_package_base() - png = base / "assets" / "mediahive-logo.png" - if png.exists(): - return png - webp = base / "assets" / "mediahive.webp" - if webp.exists(): - return webp - ico = base / "assets" / "mediahive.ico" - if ico.exists(): - return ico - icns = base / "assets" / "mediahive.icns" - if icns.exists(): - return icns - return None - - -def _startup_html() -> str: - logo_uri = "" - logo_path = _startup_logo_path() - if logo_path is not None: - try: - data = logo_path.read_bytes() - ext = logo_path.suffix.lower() - mime = { - ".png": "image/png", - ".webp": "image/webp", - ".ico": "image/x-icon", - ".icns": "image/icns", - }.get(ext, "application/octet-stream") - encoded = base64.b64encode(data).decode("ascii") - logo_uri = f"data:{mime};base64,{encoded}" - except Exception: - logo_uri = "" - return _STARTUP_HTML_TEMPLATE.replace("__MEDIAHIVE_LOGO_URI__", logo_uri) - - def _webview_start_kwargs() -> dict[str, str]: """Return platform-specific pywebview startup kwargs.""" # On macOS, force Qt backend so pywebview uses Chromium/WebEngine instead of WKWebView. @@ -986,6 +713,27 @@ def _run_initial_setup() -> str | None: return chosen[0] if chosen else None +def _normalize_media_root_input(path: str) -> Path: + """Normalize configured media path without touching filesystem. + + This intentionally avoids exists()/is_dir()/resolve() checks so startup can + continue even if macOS shows a permission dialog for the selected folder. + """ + parts = Path(path).expanduser().parts + match parts: + case (*rest, ".mediahive", "index.json"): + ... + case (*rest, ".mediahive"): + ... + case rest: + ... + + base = Path(*rest) + if not base.is_absolute(): + base = Path.cwd() / base + return base + + def _supports_gamepad_remote() -> bool: return sys.platform == "win32" @@ -1010,9 +758,8 @@ def winmain() -> None: _prepend_meipass_to_path() # In a frozen (windowed) build there is no console — redirect output to a log file - startup_log_path: Path | None = None if getattr(sys, "frozen", False): - startup_log_path = _setup_logging() + _setup_logging() # Resolution order: CLI arg → MEDIAHIVE_PATH env → saved config → ask user folder = ( @@ -1026,7 +773,7 @@ def winmain() -> None: if not folder: return # user cancelled the folder picker - mediaroot = resolve_media_root(folder) + mediaroot = _normalize_media_root_input(folder) os.environ["MEDIAHIVE_PATH"] = mediaroot.as_posix() backend_port = _reserve_backend_port() @@ -1053,56 +800,29 @@ def winmain() -> None: ) backend_thread.start() - startup_state_lock = threading.Lock() - startup_state: dict[str, object] = { - "ready": False, - "failed": False, - "message": "Starting MediaHive", - "backend_url": backend_url, - } + def _activate_initial_folder() -> None: + body = json.dumps({"folder": mediaroot.as_posix()}).encode("utf-8") + req = urllib.request.Request( + url=f"{backend_url}/api/change-folder", + data=body, + method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=10): + logger.info("Requested initial media folder activation") + except Exception as exc: + logger.warning("Initial media folder activation request failed: %s", exc) - def _request_quit() -> None: + if not _wait_for_backend(timeout=HEALTH_TIMEOUT): server.should_exit = True - - def _set_startup_state(**updates: object) -> None: - with startup_state_lock: - startup_state.update(updates) - - def _monitor_backend_ready() -> None: - _set_startup_state(message="Starting MediaHive") - while not server.should_exit: - if _wait_for_backend(timeout=5): - _set_startup_state( - ready=True, - failed=False, - message="Backend ready. Opening MediaHive...", - ) - return - if not backend_thread.is_alive(): - _set_startup_state( - ready=False, - failed=True, - message="Backend stopped unexpectedly. Check logs or quit.", - ) - return - - threading.Thread( - target=_monitor_backend_ready, - daemon=True, - name="mediahive-startup-monitor", - ).start() + raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s") api = JsApi() - api.configure_startup_bridge( - startup_state=startup_state, - startup_state_lock=startup_state_lock, - startup_log_path=startup_log_path, - request_quit_callback=_request_quit, - ) logger.info("Configured pywebview backend: %s", _selected_webview_backend()) window = webview.create_window( title="MediaHive", - html=_startup_html(), + url=backend_url, fullscreen=True, js_api=api, ) @@ -1123,6 +843,13 @@ def winmain() -> None: if poll_thread is None and _supports_gamepad_remote(): poll_thread = _start_gamepad_remote(poll_stop, mediaroot) + # Trigger initial folder activation after main UI is shown. + threading.Thread( + target=_activate_initial_folder, + daemon=True, + name="mediahive-initial-folder-activation", + ).start() + webview.start(func=on_shown, icon=_icon_path(), **_webview_start_kwargs()) poll_stop.set() diff --git a/scripts/MediaHive.spec b/scripts/MediaHive.spec index d3d4807..925b3b1 100644 --- a/scripts/MediaHive.spec +++ b/scripts/MediaHive.spec @@ -17,7 +17,6 @@ block_cipher = None _pkg = Path(mediahive.server.__file__).parent _frontend_build = _pkg / "frontend-build" _logo_webp = _pkg / "assets" / "mediahive.webp" -_logo_png = _pkg / "assets" / "mediahive-logo.png" _icon_win = _pkg / "assets" / "mediahive.ico" _icon_mac = _pkg / "assets" / "mediahive.icns" _tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg" @@ -39,8 +38,6 @@ if _icon_mac.exists(): _datas.append((str(_icon_mac), "mediahive/assets")) if _logo_webp.exists(): _datas.append((str(_logo_webp), "mediahive/assets")) -if _logo_png.exists(): - _datas.append((str(_logo_png), "mediahive/assets")) _hiddenimports = [ # uvicorn dynamic imports