From f2fc6f657f0ad5abc09579032b839e114cb05792 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 23 Sep 2026 18:41:51 +0000 Subject: [PATCH] Updated fastapi-vue-setup 1.7.2 - DEVMODE removed; FASTAPI_VUE=MEDIAHIVE is set at entrypoints and fastapi_vue.env drives dev-mode checks - CLI roots now reach the server via fastapi-vue's env(Config) teleport (mediahive.config.config) instead of MEDIAHIVE_ROOTS - Legacy media_folder config field and migration removed - mediahive.* logger level set via log_config (DEBUG in dev, INFO in prod) in both server.run() and winmain's patched uvicorn config --- docs/API.md | 2 +- frontend/vite-plugin-fastapi.js | 10 +- mediahive/__main__.py | 24 +++-- mediahive/config.py | 24 ++--- mediahive/hivescan/__main__.py | 15 +-- mediahive/server.py | 30 ++---- mediahive/winmain.py | 28 +++-- pyproject.toml | 2 +- scripts/devserver.py | 14 ++- scripts/fastapi-vue/buildutil.py | 15 ++- scripts/fastapi-vue/devutil.py | 172 ++++++++++++++----------------- 11 files changed, 165 insertions(+), 171 deletions(-) diff --git a/docs/API.md b/docs/API.md index 121d46c..9f4b1d7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -32,7 +32,7 @@ All media paths are scoped to a **root**, identified by a friendly `root_id` - `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` is constrained to `/.mediahive/{asset_type}` where `asset_type` is one of `movies`, `series`, `people`. - `GET /api/meta/{root_id}/{meta_key}` supports metadata keys currently limited to `playback-state` and `scanignore`. - `GET /api/player/status` returns `{ "remote": true|false }`. -- Roots may also be provided at startup via the `MEDIAHIVE_ROOTS` environment variable (JSON dict of name → path), which overrides the persisted configuration. +- Roots may also be provided at startup via CLI arguments (`mediahive /path/to/media ...`), which are passed to the server through fastapi-vue's env config (`mediahive.config.config`) and override the persisted configuration. - Assumed playback: after `POST /api/play/{root_id}` the launched item is assumed to be playing while the frontend reports no input activity. On the next `POST /api/activity` the guessed position (`resume base + elapsed`, capped at the TMDb runtime) is written once; watches under 5 minutes are discarded (a peek is not progress). A resume entry written by another tracker (e.g. the GUI's MPC-BE tracker) during the session overrides the guess. The MPC-BE tracker likewise ignores sessions shorter than 5 minutes. ## WebSocket diff --git a/frontend/vite-plugin-fastapi.js b/frontend/vite-plugin-fastapi.js index c635587..93639f7 100644 --- a/frontend/vite-plugin-fastapi.js +++ b/frontend/vite-plugin-fastapi.js @@ -8,11 +8,11 @@ * - Disables Vite's screen clearing on startup * * Options: - * paths - Array of paths to proxy (default: ["/api"]) + * paths - Array of paths to proxy (default: ['/api']) */ -export default function fastapiVue({ paths = ["/api"] } = {}) { - const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8421" +export default function fastapiVue({ paths = ['/api'] } = {}) { + const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || 'http://localhost:8421' // Build proxy configuration for each path const proxy = {} @@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) { } return { - name: "vite-plugin-fastapi-mediahive", + name: 'vite-plugin-fastapi-mediahive', config: () => ({ clearScreen: false, server: { proxy }, build: { - outDir: "../mediahive/frontend-build", + outDir: '../mediahive/frontend-build', emptyOutDir: true, }, }), diff --git a/mediahive/__main__.py b/mediahive/__main__.py index fdf16ed..eb5edab 100644 --- a/mediahive/__main__.py +++ b/mediahive/__main__.py @@ -1,16 +1,20 @@ """MediaHive CLI entrypoint.""" +import os + +# Must be set before fastapi_vue env bindings are created (mediahive.config). +os.environ["FASTAPI_VUE"] = "MEDIAHIVE" + import argparse import asyncio -import json -import os import sys from pathlib import Path -from fastapi_vue import server +from fastapi_vue import env, server + +from mediahive.config import config DEFAULT_PORT = 8420 -DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1" def _configure_windows_event_loop_policy() -> None: @@ -140,10 +144,11 @@ def main() -> None: name = f"{base_name}{suffix}" suffix += 1 roots[name] = p.as_posix() - os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots) + # Teleported to the server process by fastapi-vue's server.run(). + config.roots = roots if ( - DEVMODE + env.dev and sys.platform == "win32" and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1" ): @@ -156,7 +161,12 @@ def main() -> None: default_port=DEFAULT_PORT, server_header=False, loop="none" if sys.platform == "win32" else "auto", - reload=Path(__file__).parent if DEVMODE and sys.platform != "win32" else False, + reload=Path(__file__).parent if env.dev and sys.platform != "win32" else False, + # fastapi-vue routes the root logger at INFO in dev / WARNING in prod; + # keep our own loggers visible in production too. + log_config={ + "loggers": {"mediahive": {"level": "DEBUG" if env.dev else "INFO"}} + }, ) diff --git a/mediahive/config.py b/mediahive/config.py index 32e27a0..5a102bc 100644 --- a/mediahive/config.py +++ b/mediahive/config.py @@ -13,14 +13,20 @@ from pathlib import Path import msgspec import msgspec.toml +from fastapi_vue import env from platformdirs import user_config_path, user_log_path class Config(msgspec.Struct, omit_defaults=True): - media_folder: str | None = None roots: dict[str, str] | None = None +# Runtime config shared between the CLI entrypoint and the server process via +# fastapi-vue's env teleport (MEDIAHIVE_CONFIG). Values set here take +# precedence over the persisted config file. +config = env(Config) + + def config_dir() -> Path: # appauthor=False: avoid the doubled %LOCALAPPDATA%\mediahive\mediahive. # roaming=False: config is machine-specific state, not something to sync @@ -37,25 +43,11 @@ def config_path() -> Path: return config_dir() / "config.toml" -def _migrate_legacy_media_folder(cfg: Config) -> Config: - """If roots is empty but media_folder exists, seed roots with it.""" - if cfg.roots: - return cfg - if not cfg.media_folder: - return cfg - path = Path(cfg.media_folder) - name = path.name or path.anchor.strip("/\\").lower() or "media" - # Resolve collisions simply by using the basename; if user had weird layout - # they can rename via the UI later. - return msgspec.structs.replace(cfg, roots={name: cfg.media_folder}) - - def load_config() -> Config: path = config_path() if path.exists(): try: - cfg = msgspec.toml.decode(path.read_bytes(), type=Config) - return _migrate_legacy_media_folder(cfg) + return msgspec.toml.decode(path.read_bytes(), type=Config) except OSError, msgspec.DecodeError, msgspec.ValidationError: return Config() return Config() diff --git a/mediahive/hivescan/__main__.py b/mediahive/hivescan/__main__.py index 974961f..cb6ed95 100644 --- a/mediahive/hivescan/__main__.py +++ b/mediahive/hivescan/__main__.py @@ -1,13 +1,18 @@ """Hivescan CLI entrypoint.""" +import os + +# Must be set before fastapi_vue env bindings are created (mediahive.config). +os.environ.setdefault("FASTAPI_VUE", "MEDIAHIVE") + import argparse import asyncio -import json import logging -import os import sys from pathlib import Path +from mediahive.config import config + def _configure_windows_event_loop_policy() -> None: """Ensure Windows uses Proactor loop so asyncio subprocess APIs are available.""" @@ -55,11 +60,9 @@ The server exposes a unified endpoint: args = parser.parse_args() - # Defer filesystem validation to the server; pass raw path via env. + # Defer filesystem validation to the server; pass raw path via env config. media_root = Path(args.media_folder).expanduser() - os.environ["MEDIAHIVE_ROOTS"] = json.dumps({ - media_root.name or "media": media_root.as_posix() - }) + config.roots = {media_root.name or "media": media_root.as_posix()} logging.basicConfig( level=logging.INFO, diff --git a/mediahive/server.py b/mediahive/server.py index a169ca8..90626b9 100644 --- a/mediahive/server.py +++ b/mediahive/server.py @@ -37,10 +37,9 @@ from fastapi.responses import ( Response, StreamingResponse, ) -from fastapi_vue import Frontend +from fastapi_vue import Frontend, env -from mediahive.__main__ import DEVMODE -from mediahive.config import load_config, log_dir +from mediahive.config import config, load_config, log_dir from mediahive.hivescan.images import close_image_client from mediahive.hivescan.scanner import RootScanner from mediahive.hivescan.tmdb_client import close_http_client @@ -974,23 +973,14 @@ async def _activate_all_roots() -> None: """ desired: dict[str, str] = {} - # 1. CLI roots via MEDIAHIVE_ROOTS (JSON dict) - env_roots_raw = os.environ.get("MEDIAHIVE_ROOTS") - env_roots: dict[str, str] | None = None - if env_roots_raw: - try: - parsed = json.loads(env_roots_raw) - if isinstance(parsed, dict): - env_roots = parsed - except Exception: - logger.exception("Failed to parse MEDIAHIVE_ROOTS") - + # 1. CLI roots (teleported via fastapi-vue's env config) take precedence # 2. Persisted config roots (used only when CLI roots are not provided) - cfg = load_config() - if env_roots is not None: - desired.update(env_roots) - elif cfg.roots: - desired.update(cfg.roots) + if config.roots: + desired.update(config.roots) + else: + cfg = load_config() + if cfg.roots: + desired.update(cfg.roots) if not desired: logger.info("No roots configured; waiting for PUT /api/config/roots") @@ -1056,7 +1046,7 @@ async def lifespan(_app: FastAPI): await close_image_client() -app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=DEVMODE) +app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=env.dev) # Allow CORS for development app.add_middleware( diff --git a/mediahive/winmain.py b/mediahive/winmain.py index 9b0d55e..3506567 100644 --- a/mediahive/winmain.py +++ b/mediahive/winmain.py @@ -24,15 +24,20 @@ import urllib.request from concurrent.futures import Future, ThreadPoolExecutor from pathlib import Path +# Must be set before fastapi_vue env bindings are created (mediahive.config); +# this module is the PyInstaller entry point and may run without __main__. +os.environ.setdefault("FASTAPI_VUE", "MEDIAHIVE") + import msgspec.structs import uvicorn import velopack import webview +from fastapi_vue import env from fastapi_vue.logging import patch_log_config from fastapi_vue.startupbox import print_box from tracerite.html import html_traceback -from mediahive.config import load_config, log_dir, save_config +from mediahive.config import config, load_config, log_dir, save_config from mediahive.volume_control import get_volume, set_volume, volume_max logger = logging.getLogger("mediahive.winmain") @@ -1202,10 +1207,6 @@ def winmain() -> None: initial_roots[name] = p.as_posix() elif cfg.roots: initial_roots = cfg.roots - elif cfg.media_folder: - p = _normalize_media_root_input(cfg.media_folder) - name = p.name or "media" - initial_roots[name] = p.as_posix() if not initial_roots: folder = _run_initial_setup() @@ -1219,8 +1220,9 @@ def winmain() -> None: if cfg.roots != initial_roots: save_config(msgspec.structs.replace(cfg, roots=initial_roots)) - # Pass roots to the server via env (validation deferred to server startup) - os.environ["MEDIAHIVE_ROOTS"] = json.dumps(initial_roots) + # Pass roots to the in-process server via the shared env config + # (validation deferred to server startup) + config.roots = initial_roots backend_port = _reserve_backend_port() backend_url = f"http://{BACKEND_HOST}:{backend_port}" @@ -1238,7 +1240,13 @@ def winmain() -> None: # log config wires up its access-log middleware, emoji level prefixes and # tracerite tracebacks (colors are auto-disabled when stderr is not a tty, # e.g. redirected to the log file in frozen builds). - config = uvicorn.Config( + log_config = patch_log_config(uvicorn.config.LOGGING_CONFIG) + # fastapi-vue routes the root logger at INFO in dev / WARNING in prod; + # keep our own loggers visible in production too. + log_config.setdefault("loggers", {})["mediahive"] = { + "level": "DEBUG" if env.dev else "INFO" + } + uvicorn_config = uvicorn.Config( "mediahive.server:app", host=BACKEND_HOST, port=backend_port, @@ -1246,9 +1254,9 @@ def winmain() -> None: server_header=False, timeout_graceful_shutdown=0, access_log=False, # fastapi-vue's middleware replaces uvicorn's - log_config=patch_log_config(uvicorn.config.LOGGING_CONFIG), + log_config=log_config, ) - server = uvicorn.Server(config) + server = uvicorn.Server(uvicorn_config) backend_thread = threading.Thread( target=server.run, daemon=True, name="mediahive-backend" ) diff --git a/pyproject.toml b/pyproject.toml index fd56f64..af08f94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "aiofiles>=25.1.0", "aiopathlib>=0.6.0", "bencodepy>=0.9.5", - "fastapi-vue>=1.4.1", + "fastapi-vue~=1.7.2", "fastapi[standard]>=0.128.0", "httpx[http2]>=0.28.1", "msgspec>=0.19", diff --git a/scripts/devserver.py b/scripts/devserver.py index d532852..0c2b3c0 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -5,8 +5,8 @@ import argparse import asyncio import os +import subprocess import sys -from contextlib import suppress from pathlib import Path import tracerite @@ -48,11 +48,11 @@ async def run_devserver( os.environ["MEDIAHIVE_DEV"] = "1" async with ProcessGroup() as pg: + pg.create_task(check_ports_free(viteurl, backurl)) npm_i = await pg.spawn(*npm_install, cwd=front) - await check_ports_free(viteurl, backurl) - await pg.spawn(*mediahive, *(extra_args or [])) + await pg.spawn(*mediahive, *(extra_args or []), vital=True) await pg.wait(npm_i, ready(backurl, path=HEALTH)) - await pg.spawn(*vite, cwd=front) + await pg.spawn(*vite, cwd=front, vital=True) def main() -> None: @@ -75,8 +75,12 @@ def main() -> None: help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})", ) args, extra_args = parser.parse_known_args() - with suppress(KeyboardInterrupt): + try: asyncio.run(run_devserver(args.listen, args.backend, extra_args)) + except* KeyboardInterrupt: + pass # user stopped the devserver: normal exit + except* subprocess.SubprocessError, RuntimeError: + raise SystemExit(1) from None # logged in devutil already; exit 1 HELP_EPILOG = """ diff --git a/scripts/fastapi-vue/buildutil.py b/scripts/fastapi-vue/buildutil.py index 9f6875b..02f7b41 100644 --- a/scripts/fastapi-vue/buildutil.py +++ b/scripts/fastapi-vue/buildutil.py @@ -10,20 +10,27 @@ from pathlib import Path MIN_NODE_VERSION = 20 -class _PrefixFormatter(logging.Formatter): - """Formatter that adds prefix based on log level.""" +class _Formatter(logging.Formatter): + """Prefix formatter, intentionally different from fastapi_vue.logging. + + INFO and below pass through unprefixed so messages can use their own + markings (>>>, ###); WARNING and above get an emoji prefix. + """ def format(self, record: logging.LogRecord) -> str: + if record.levelno >= logging.ERROR: + return f"🛑 {record.getMessage()}" if record.levelno >= logging.WARNING: - return f"⚠️ {record.getMessage()}" + return f"💣 {record.getMessage()}" return record.getMessage() _handler = logging.StreamHandler() -_handler.setFormatter(_PrefixFormatter()) +_handler.setFormatter(_Formatter()) logger = logging.getLogger("fastapi-vue") logger.addHandler(_handler) logger.setLevel(logging.INFO) +logger.propagate = False # own handler; do not double-print via a configured root def _check_node_version(node_path: str) -> None: diff --git a/scripts/fastapi-vue/devutil.py b/scripts/fastapi-vue/devutil.py index befc88e..5404995 100644 --- a/scripts/fastapi-vue/devutil.py +++ b/scripts/fastapi-vue/devutil.py @@ -1,110 +1,89 @@ """Utilities meant for devserver script, used only in source repository with dev deps.""" +from __future__ import annotations + import asyncio -import subprocess import sys +from asyncio.subprocess import Process from contextlib import suppress from pathlib import Path -from typing import TYPE_CHECKING, Any, Self +from subprocess import CalledProcessError +from typing import TYPE_CHECKING, Any from urllib.parse import urlsplit from buildutil import find_dev_tool, find_install_tool, logger from fastapi_vue.hostutil import parse_endpoint if TYPE_CHECKING: - from collections.abc import Coroutine + from collections.abc import Awaitable -class ProcessGroup: - """Manage async subprocesses with automatic cleanup, like TaskGroup for processes.""" +class ProcessGroup(asyncio.TaskGroup): + """TaskGroup with structured ownership of async subprocesses.""" - def __init__(self) -> None: - """Initialize empty process tracking.""" - self._procs: list[asyncio.subprocess.Process] = [] - self._cmds: dict[int, str] = {} # pid -> command name + def __init__(self, *, terminate_timeout: float = 10) -> None: + """Set the grace period before terminate() escalates to kill().""" + super().__init__() + self._terminate_timeout = terminate_timeout + self._cmds: dict[Process, tuple[str, ...]] = {} async def spawn( - self, - *cmd: str, - cwd: str | None = None, - ) -> asyncio.subprocess.Process: - """Spawn a subprocess and track it.""" - cmd_name = Path(cmd[0]).stem - logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]])) - proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd) - self._procs.append(proc) - self._cmds[proc.pid] = cmd_name - return proc + self, *cmd: str, cwd: str | None = None, vital: bool = False + ) -> Process: + """Spawn and own a subprocess. If a vital process exits, the group cancels.""" - async def wait( - self, - *waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any], - ) -> None: - """Wait for processes/coroutines to complete, raise SystemExit on failure.""" + async def run() -> None: + name = Path(cmd[0]).stem + logger.info(">>> %s", " ".join([name, *cmd[1:]])) + try: + proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd) + self._cmds[proc] = cmd + started.set_result(proc) + except Exception as e: # ruff: ignore[blind-except] + started.set_exception(e) + return - async def wait_proc(proc: asyncio.subprocess.Process) -> None: - returncode = await proc.wait() - if returncode != 0: - cmd_name = self._cmds.get(proc.pid, "unknown") - raise subprocess.CalledProcessError(returncode, cmd_name) - - tasks = [ - wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w - for w in waitables - ] - try: - await asyncio.gather(*tasks) - except subprocess.CalledProcessError as e: - logger.warning("%s failed with exit status %d", e.cmd, e.returncode) - raise SystemExit(1) from None - - async def __aenter__(self) -> Self: - """Enter the async context manager.""" - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None: - """Wait for one process to exit, terminate others, then wait for all.""" - await self._cleanup(immediate=exc_type is not None) - - async def _cleanup(self, *, immediate: bool = False) -> None: - running = [p for p in self._procs if p.returncode is None] - if not running: - return - - if not immediate: - # Wait for any one process to exit - with suppress(asyncio.CancelledError): - await asyncio.wait( - [asyncio.create_task(p.wait()) for p in running], - return_when=asyncio.FIRST_COMPLETED, - ) - - # Terminate remaining processes - for p in self._procs: - if p.returncode is None: + try: + returncode = await proc.wait() + finally: with suppress(ProcessLookupError): - p.terminate() - - # Wait for all to finish (with overall timeout), shielded from cancellation - still_running = [p for p in self._procs if p.returncode is None] - if still_running: - with suppress(asyncio.CancelledError): + proc.terminate() try: - await asyncio.shield( - asyncio.wait_for( - asyncio.gather(*[p.wait() for p in still_running]), - timeout=10, - ), - ) + await asyncio.wait_for(proc.wait(), self._terminate_timeout) except TimeoutError: - for p in self._procs: - if p.returncode is None: - with suppress(ProcessLookupError): - p.kill() - await p.wait() + with suppress(ProcessLookupError): + proc.kill() + await proc.wait() + + if vital: + logger.warning("Vital process %s exited", name) + raise CalledProcessError(returncode, cmd) + + started = asyncio.get_running_loop().create_future() + self.create_task(run()) + return await asyncio.shield(started) + + async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]: + """Wait concurrently and return results in argument order.""" + + async def task(w: Process | Awaitable) -> Any: + if not isinstance(w, Process): + return await w + if retcode := await w.wait(): + cmd = self._cmds[w] + logger.warning( + "Process %s exited with status %d", Path(cmd[0]).stem, retcode + ) + raise CalledProcessError(retcode, cmd) + return retcode + + async with asyncio.TaskGroup() as group: + tasks = [group.create_task(task(w)) for w in waitables] + + return tuple(task.result() for task in tasks) -async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109 +async def http_get_server(url: str, timeout: float) -> str | None: # ruff: ignore[async-function-with-timeout] """GET url with plain asyncio streams, return the response Server header. Returns an empty string when the server responds without a Server header, @@ -127,42 +106,43 @@ async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYN writer.close() except OSError, EOFError, ValueError, TimeoutError: return None - for line in data.decode("latin-1").split("\r\n"): + for line in data.decode(errors="replace").split("\r\n"): if line.lower().startswith("server:"): - return line.split(":", 1)[1].strip() + return line[7:].strip() return "" async def check_ports_free(*urls: str) -> None: - """Verify URLs are not responding (ports are free). Raise SystemExit if any respond.""" + """Verify URLs are not responding (ports are free). - async def check(url: str) -> None: - server = await http_get_server(url, timeout=0.1) + Meant to run as a task inside a TaskGroup. Logs the conflict and raises + RuntimeError (handled like a failed process) if any URL responds. + """ + servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls)) + for url, server in zip(urls, servers, strict=True): if server is not None: - logger.warning( + logger.error( "Conflicting %s already running at %s", server or "server", url ) - raise SystemExit(1) - - await asyncio.gather(*[check(url) for url in urls]) + raise RuntimeError(url) async def ready(url: str, path: str = "", max_attempts: int = 50) -> None: """Wait for the server to be ready by polling an endpoint. Use empty path to disable the check and make this return immediately. - Raises SystemExit(1) if server doesn't start in time. + Logs, then raises RuntimeError if the server doesn't start in time. """ if not path: return for attempt in range(max_attempts): if await http_get_server(f"{url}{path}", timeout=1.0) is not None: - logger.info("✓ Backend ready!") + logger.info("🟢 Backend ready!") return if attempt == max_attempts - 1: - logger.warning("Backend didn't start in time") - raise SystemExit(1) + logger.error("Backend at %s didn't start in time", url) + raise RuntimeError(url) await asyncio.sleep(0.1)