Simplify startup flow and defer media root activation

This commit is contained in:
2026-05-22 16:30:19 +00:00
parent 33b59bbace
commit be140bc286
4 changed files with 127 additions and 389 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

+80 -66
View File
@@ -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")
+47 -320
View File
@@ -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 = """<!DOCTYPE html>
</body></html>"""
_STARTUP_HTML_TEMPLATE = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>MediaHive</title><style>
:root {
--bg: #090909;
--bg-alt: #151515;
--fg: #eef2f7;
--muted: #b0b0b0;
--accent: #f2513b;
--border: #2b2b2b;
}
* { box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
font-family: Avenir, "Segoe UI", system-ui, sans-serif;
color: var(--fg);
background:
radial-gradient(1200px 680px at 12% -5%, #1c1c1c 0%, transparent 58%),
radial-gradient(980px 540px at 110% 105%, #2a1b19 0%, transparent 58%),
linear-gradient(150deg, var(--bg) 0%, var(--bg-alt) 100%);
}
.shell {
width: 100%;
height: 100%;
display: grid;
grid-template-rows: 1fr auto;
padding: 4vh 4vw;
gap: 3vh;
}
.hero-wrap {
display: grid;
place-items: start center;
text-align: center;
padding-top: clamp(30vh, 33vh, 36vh);
}
.logo {
width: min(630px, 54.6vw);
height: min(238px, 23.8vh);
max-width: 63vw;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
user-select: none;
}
.panel {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 28vh;
max-height: 36vh;
border: 1px solid var(--border);
border-radius: 14px;
background: rgba(8, 8, 8, 0.76);
backdrop-filter: blur(3px);
overflow: hidden;
}
.status {
padding: 14px 16px;
color: var(--muted);
font-size: 18px;
}
.log {
margin: 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: rgba(8, 12, 16, 0.86);
color: #d4dae1;
font: 12px/1.45 ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow: auto;
padding: 12px 16px;
}
.actions {
display: flex;
gap: 10px;
justify-content: flex-end;
padding: 12px 16px;
}
button {
border: 1px solid var(--border);
border-radius: 9px;
background: #151b22;
color: var(--fg);
padding: 10px 14px;
font-size: 14px;
cursor: pointer;
}
button:hover { filter: brightness(1.07); }
.primary {
border-color: color-mix(in srgb, var(--accent) 45%, #fff 0%);
background: linear-gradient(180deg, var(--accent) 0%, #cf412f 100%);
color: #fff;
}
.quiet {
background: #151b22;
color: #c4ccd6;
}
.pulse::after {
content: "";
display: inline-block;
width: 8px;
height: 8px;
margin-left: 10px;
border-radius: 50%;
background: var(--accent);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.3; transform: scale(0.9); }
50% { opacity: 1; transform: scale(1.1); }
}
</style></head><body>
<main class="shell">
<section class="hero-wrap">
<div
class="logo"
role="img"
aria-label="MediaHive"
style="background-image: url('__MEDIAHIVE_LOGO_URI__');"
></div>
</section>
<section class="panel">
<div id="status" class="status pulse">Starting MediaHive</div>
<pre id="log" class="log">Waiting for startup logs...</pre>
<div class="actions">
<button id="quit" class="primary" type="button">Quit</button>
</div>
</section>
</main>
<script>
const statusEl = document.getElementById('status');
const logEl = document.getElementById('log');
const quitBtn = document.getElementById('quit');
let navigating = false;
async function refresh() {
if (!window.pywebview || !window.pywebview.api || navigating) {
return;
}
try {
const status = await window.pywebview.api.startup_status();
if (status && typeof status.message === 'string') {
statusEl.textContent = status.message;
if (status.ready) {
statusEl.classList.remove('pulse');
}
}
const logText = await window.pywebview.api.startup_log_tail();
if (typeof logText === 'string' && logText.length > 0) {
logEl.textContent = logText;
logEl.scrollTop = logEl.scrollHeight;
}
if (status && status.ready && status.backend_url) {
navigating = true;
window.location.href = status.backend_url;
}
} catch (err) {
statusEl.textContent = 'Waiting for startup bridge...';
}
}
quitBtn.addEventListener('click', async () => {
if (!window.pywebview || !window.pywebview.api) {
return;
}
await window.pywebview.api.quit_app();
});
setInterval(refresh, 500);
refresh();
</script>
</body></html>"""
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()
-3
View File
@@ -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