Files
mediahive/mediahive/server.py
T
LeoVasanko eeb28e991f Use Windows Shell API for reliable Explorer file selection
Use SHParseDisplayName + SHOpenFolderAndSelectItems to open the parent folder and select files with special-character paths. Keep explorer.exe /n,/select as a fallback when the API path fails.
2026-05-24 19:32:36 +00:00

623 lines
19 KiB
Python

"""
FastAPI server for MediaHive.
Serves media files, the Vue frontend, and runs the continuous scanning
pipeline with live WebSocket updates. Excluded paths are controlled by
``.mediahive/scanignore`` (gitignore-style syntax).
"""
from __future__ import annotations
import asyncio
import ctypes
import json
import logging
import mimetypes
import os
import re
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from contextlib import asynccontextmanager
from pathlib import Path
import aiofiles
import msgspec
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from fastapi_vue import Frontend
from mediahive.__main__ import DEVMODE
from mediahive.config import load_config
from mediahive.hivescan.scanner import RootScanner
from mediahive.models.protocol import (
MsgspecResponse,
OpenFolderRequest,
PlayMediaRequest,
RootsRequest,
)
from mediahive.root_registry import Supervisor, compute_root_id
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/"])
# Supervisor manages all root contexts
supervisor = Supervisor()
_RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
if sys.platform == "win32":
from ctypes import wintypes
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_context(root_id: str):
ctx = supervisor.get(root_id)
if ctx is None:
raise HTTPException(status_code=404, detail=f"Root not found: {root_id}")
return ctx
def _load_resume_positions(root_path: Path) -> dict[str, int]:
playback_state_path = root_path / ".mediahive" / "playback-state.json"
try:
raw = json.loads(playback_state_path.read_text(encoding="utf-8"))
except Exception:
return {}
resume_positions = raw.get("resume_positions") if isinstance(raw, dict) else None
if not isinstance(resume_positions, dict):
return {}
cleaned: dict[str, int] = {}
for key, value in resume_positions.items():
if isinstance(key, str) and isinstance(value, (int, float)):
cleaned[key] = max(0, int(value))
return cleaned
def _open_with_default_app(path: Path) -> None:
if sys.platform == "win32":
os.startfile(str(path))
return
opener = "open" if sys.platform == "darwin" else "xdg-open"
subprocess.Popen([opener, str(path)], **_POPEN_KWARGS)
def _select_file_in_windows_explorer(path: Path) -> bool:
"""Select a file in Explorer using Shell APIs to avoid CLI parsing issues."""
if sys.platform != "win32":
return False
if not path.exists() or not path.is_file():
return False
# Use shell32 APIs directly; this avoids explorer.exe argument parsing edge cases.
ole32 = ctypes.OleDLL("ole32")
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
pidl_folder = wintypes.LPVOID()
pidl_file = wintypes.LPVOID()
sh_parse_display_name = shell32.SHParseDisplayName
sh_parse_display_name.argtypes = [
wintypes.LPCWSTR,
wintypes.LPVOID,
ctypes.POINTER(wintypes.LPVOID),
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
]
sh_parse_display_name.restype = ctypes.c_long
sh_open_folder_and_select_items = shell32.SHOpenFolderAndSelectItems
sh_open_folder_and_select_items.argtypes = [
wintypes.LPVOID,
wintypes.UINT,
ctypes.POINTER(wintypes.LPVOID),
wintypes.DWORD,
]
sh_open_folder_and_select_items.restype = ctypes.c_long
co_initialize = ole32.CoInitialize
co_initialize.argtypes = [wintypes.LPVOID]
co_initialize.restype = ctypes.c_long
co_uninitialize = ole32.CoUninitialize
co_uninitialize.argtypes = []
co_uninitialize.restype = None
co_task_mem_free = ole32.CoTaskMemFree
co_task_mem_free.argtypes = [wintypes.LPVOID]
co_task_mem_free.restype = None
hr = co_initialize(None)
if hr < 0:
return False
try:
attrs = wintypes.DWORD(0)
folder_path = str(path.parent)
hr = sh_parse_display_name(
folder_path,
None,
ctypes.byref(pidl_folder),
0,
ctypes.byref(attrs),
)
if hr < 0:
return False
attrs2 = wintypes.DWORD(0)
file_path = str(path)
hr = sh_parse_display_name(
file_path,
None,
ctypes.byref(pidl_file),
0,
ctypes.byref(attrs2),
)
if hr < 0:
return False
item_array = (wintypes.LPVOID * 1)()
item_array[0] = pidl_file
hr = sh_open_folder_and_select_items(pidl_folder, 1, item_array, 0)
return hr >= 0
finally:
if pidl_file:
co_task_mem_free(pidl_file)
if pidl_folder:
co_task_mem_free(pidl_folder)
co_uninitialize()
def _parse_range_header(range_header: str, file_size: int) -> tuple[int, int]:
"""Parse a single HTTP bytes range header into inclusive start/end offsets."""
match = _RANGE_RE.fullmatch(range_header.strip())
if not match:
raise HTTPException(
status_code=416,
detail="Invalid Range header",
headers={"Content-Range": f"bytes */{file_size}"},
)
start_str, end_str = match.groups()
if not start_str and not end_str:
raise HTTPException(
status_code=416,
detail="Invalid Range header",
headers={"Content-Range": f"bytes */{file_size}"},
)
if not start_str:
suffix_length = int(end_str)
if suffix_length <= 0:
raise HTTPException(
status_code=416,
detail="Invalid Range header",
headers={"Content-Range": f"bytes */{file_size}"},
)
start = max(file_size - suffix_length, 0)
end = file_size - 1
else:
start = int(start_str)
end = int(end_str) if end_str else file_size - 1
if file_size <= 0 or start >= file_size or start < 0 or end < start:
raise HTTPException(
status_code=416,
detail="Requested range not satisfiable",
headers={"Content-Range": f"bytes */{file_size}"},
)
return start, min(end, file_size - 1)
def _validate_root_paths(roots: dict[str, str]) -> dict[str, str]:
"""Validate root paths on the filesystem.
Runs in a thread pool so macOS permission dialogs (and other blocking
filesystem checks) do not halt the asyncio event loop.
"""
validated: dict[str, str] = {}
for name, path_str in roots.items():
configured_posix = Path(path_str).expanduser().as_posix()
if (
len(configured_posix) == 2
and configured_posix[1] == ":"
and configured_posix[0].isalpha()
):
configured_posix = f"{configured_posix}/"
configured_path = Path(configured_posix)
if not configured_path.exists() or not configured_path.is_dir():
logger.warning("Root path invalid, skipping: %s", path_str)
continue
# Keep the configured path form (POSIX separators) so downstream naming
# can reflect user intent (e.g. mapped drive "Z:") instead of UNC.
validated[name] = configured_posix
return validated
async def _attach_scanners() -> None:
"""Ensure every active root context has a running scanner."""
for ctx in supervisor.all_contexts().values():
if ctx.scanner is None and ctx.status in ("ready", "loading"):
try:
scanner = RootScanner(ctx.root_id, ctx.root_path, ctx.send_event)
await scanner.start()
ctx.scanner = scanner
except Exception:
logger.exception("Failed to attach scanner for root %s", ctx.root_id)
async def _activate_all_roots() -> None:
"""Background task: validate and activate all configured roots.
This is deferred from lifespan startup so the server can begin accepting
requests immediately. Filesystem validation runs in a thread pool to avoid
blocking the event loop (and to let macOS permission dialogs appear without
stalling the server).
"""
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")
# 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 not desired:
logger.info("No roots configured; waiting for PUT /api/roots")
return
# Validate paths in a thread pool (macOS permission-dialog safe)
validated = await asyncio.to_thread(_validate_root_paths, desired)
if not validated:
logger.warning("No valid roots found after validation")
return
try:
await supervisor.replace_roots(validated)
except Exception:
logger.exception("Failed to replace roots during background activation")
return
await _attach_scanners()
logger.info("Background root activation complete; %d root(s) active", len(supervisor.all_contexts()))
# ---------------------------------------------------------------------------
# Lifespan
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
await frontend.load()
# Defer root activation to a background task so the server starts
# immediately and macOS permission dialogs do not block startup.
activation_task = asyncio.create_task(_activate_all_roots())
logger.info("Server ready; waiting for root activation")
yield
activation_task.cancel()
try:
await activation_task
except asyncio.CancelledError:
pass
await supervisor.shutdown()
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=DEVMODE)
# Allow CORS for development
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# API Endpoints
# ---------------------------------------------------------------------------
@app.get("/api/health")
async def health_check():
"""Health check endpoint."""
return {"status": "ok"}
@app.get("/api/config")
async def get_config():
"""Return current server configuration."""
cfg = load_config()
return {"roots": cfg.roots}
# --- Root management ---
@app.get("/api/roots")
async def get_roots():
"""List all active roots with their status."""
return {"roots": supervisor.all_statuses()}
@app.put("/api/roots")
async def put_roots(request: Request):
"""Atomically replace the full root set."""
body = msgspec.json.decode(await request.body(), type=RootsRequest)
accepted, failed = await supervisor.replace_roots(body.roots)
# Start scanners for newly accepted roots
await _attach_scanners()
return {
"status": "ok",
"accepted": [
{"name": e.name, "path": e.path, "root_id": e.root_id} for e in accepted
],
"failed": failed,
}
@app.get("/api/roots/{root_id}/index")
async def get_root_index(root_id: str):
"""Return the full index for a single root."""
ctx = _get_context(root_id)
return MsgspecResponse(ctx.store.get_full_index())
@app.get("/api/roots/{root_id}/status")
async def get_root_status(root_id: str):
"""Return status for a single root."""
ctx = _get_context(root_id)
scanning = ctx.scanner is not None and ctx.scanner.is_scanning()
return {
"root_id": ctx.root_id,
"path": ctx.root_path.as_posix(),
"status": ctx.status,
"error": ctx.error,
"scanning": scanning,
"movies": len(ctx.store.movies),
"series": len(ctx.store.series),
"showreel_queue": ctx.scanner.showreel_queue_size() if ctx.scanner else 0,
}
@app.post("/api/roots/{root_id}/scan")
async def trigger_root_scan(root_id: str):
"""Trigger a scan for a single root."""
ctx = _get_context(root_id)
if ctx.scanner is None:
raise HTTPException(status_code=503, detail="Scanner not active")
started = ctx.scanner.trigger_scan()
return {"status": "started" if started else "already_running"}
# --- Per-root WebSocket ---
@app.websocket("/api/roots/{root_id}/ws")
async def ws_endpoint(ws: WebSocket, root_id: str):
"""Live index updates and task progress for a single root."""
ctx = supervisor.get(root_id)
if ctx is None:
await ws.close(code=1008, reason="Unknown root")
return
await ctx.store.connect(ws)
try:
while True:
await ws.receive_text()
except WebSocketDisconnect:
ctx.store.disconnect(ws)
except Exception:
ctx.store.disconnect(ws)
# --- Media actions ---
@app.post("/api/roots/{root_id}/play")
async def play_media(root_id: str, request: Request):
"""Open a media file with the system's default player."""
ctx = _get_context(root_id)
req = msgspec.json.decode(await request.body(), type=PlayMediaRequest)
file_path = ctx.root_path / req.file_path
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"File not found: {req.file_path}")
try:
_open_with_default_app(file_path)
return {"status": "ok"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to play media: {e}")
@app.post("/api/roots/{root_id}/open-folder")
async def open_folder(root_id: str, request: Request):
"""Open a folder in the system file explorer."""
ctx = _get_context(root_id)
req = msgspec.json.decode(await request.body(), type=OpenFolderRequest)
target_path = ctx.root_path / req.folder_path
if not target_path.exists():
raise HTTPException(
status_code=404, detail=f"Path not found: {req.folder_path}"
)
try:
if sys.platform == "win32":
native_path = str(target_path).replace("/", "\\")
if target_path.is_file():
if not _select_file_in_windows_explorer(target_path):
select_arg = f'/n,/select,"{native_path}"'
subprocess.Popen(
["explorer.exe", select_arg], **_POPEN_KWARGS
)
else:
subprocess.Popen(["explorer.exe", native_path], **_POPEN_KWARGS)
elif sys.platform == "darwin":
if target_path.is_file():
subprocess.Popen(["open", "-R", str(target_path)])
else:
subprocess.Popen(["open", str(target_path)])
else:
folder = target_path.parent if target_path.is_file() else target_path
subprocess.Popen(["xdg-open", str(folder)])
return {"status": "ok"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to open folder: {e}")
@app.get("/api/roots/{root_id}/playback/resume-positions")
async def root_playback_resume_positions(root_id: str):
"""Return saved per-file resume positions under a specific root."""
ctx = _get_context(root_id)
return {"resume_positions": _load_resume_positions(ctx.root_path)}
# --- MPC-BE / Player status ---
@app.get("/api/mpcbe/status")
async def mpcbe_status():
"""Check whether MPC-BE web interface is reachable."""
return {"reachable": _mpcbe_request("/")}
@app.get("/api/player/status")
async def player_status():
"""Return whether remote player control is currently available."""
return {"remote": _mpcbe_request("/")}
def _mpcbe_request(path: str, timeout: float = 0.75) -> bool:
"""Call MPC-BE's local web interface and return True on HTTP success."""
if sys.platform != "win32":
return False
url = f"{MPC_BE_BASE_URL}{path}"
req = urllib.request.Request(url=url, method="GET")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return 200 <= resp.status < 300
except (urllib.error.URLError, TimeoutError, OSError):
return False
# --- Media file serving ---
@app.get("/api/media/{root_id}/{file_path:path}")
async def serve_media_file(root_id: str, file_path: str, request: Request):
"""Serve a media file asynchronously, scoped to a root."""
ctx = _get_context(root_id)
full_path = ctx.root_path / file_path.lstrip("/")
# Security: ensure path doesn't escape base
try:
full_path.resolve().relative_to(ctx.root_path.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
if not full_path.exists():
raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
if not full_path.is_file():
raise HTTPException(status_code=400, detail="Not a file")
file_size = full_path.stat().st_size
content_type, _ = mimetypes.guess_type(str(full_path))
if content_type is None:
content_type = "application/octet-stream"
if content_type.startswith("image/"):
return FileResponse(
full_path,
media_type=content_type,
headers={"Cache-Control": "public, max-age=86400"},
)
async def stream_file(start: int, end: int):
async with aiofiles.open(full_path, "rb") as f:
await f.seek(start)
remaining = end - start + 1
while remaining > 0:
chunk = await f.read(min(64 * 1024, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
headers = {
"Cache-Control": "public, max-age=86400",
"Accept-Ranges": "bytes",
}
range_header = request.headers.get("range")
if range_header:
start, end = _parse_range_header(range_header, file_size)
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
headers["Content-Length"] = str(end - start + 1)
return StreamingResponse(
stream_file(start, end),
status_code=206,
media_type=content_type,
headers=headers,
)
headers["Content-Length"] = str(file_size)
return StreamingResponse(
stream_file(0, file_size - 1),
media_type=content_type,
headers=headers,
)
# Serve the Vue frontend (needs to be last if SPA catch-all is used)
frontend.route(app, "/")