Simplify startup flow and defer media root activation
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
+80
-66
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user