Files
mediahive/mediahive/server.py
T

1288 lines
42 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 threading
import time
import urllib.error
import urllib.request
from collections import deque
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from datetime import datetime
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, Response, StreamingResponse
from fastapi_vue import Frontend
from mediahive.__main__ import DEVMODE
from mediahive.access_logging import (
AccessLogMiddleware,
configure_access_logging,
log_ws_close,
log_ws_open,
)
from mediahive.config import load_config
from mediahive.hivescan.images import close_image_client
from mediahive.hivescan.scanner import RootScanner
from mediahive.hivescan.tmdb_client import close_http_client
from mediahive.models.events import Remove, Task, Upsert
from mediahive.models.protocol import (
OpenFolderRequest,
PlaybackStateUpdateRequest,
PlayMediaRequest,
RootsRequest,
WsInit,
WsRemove,
WsRootInitData,
WsRoots,
WsRootStatus,
WsTask,
WsUpsert,
)
from mediahive.players import detect_players, launch_player
from mediahive.root_registry import Supervisor
logger = logging.getLogger("mediahive.server")
configure_access_logging()
MPC_BE_DEFAULT_PORT = 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/"], spa=True
)
# Supervisor manages all root contexts
supervisor = Supervisor()
_attach_scanners_lock = asyncio.Lock()
_RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
_ROOT_ASSET_TYPES = {"movies", "series", "people"}
if sys.platform == "win32":
from ctypes import wintypes
@dataclass
class _PlaybackEntry:
"""Single resume position entry with timestamp."""
pos: int
ts: datetime
def to_dict(self) -> dict:
return {
"pos": self.pos,
"ts": self.ts,
}
@staticmethod
def from_dict(data: dict) -> _PlaybackEntry | None:
if not isinstance(data, dict):
return None
pos = data.get("pos")
ts = data.get("ts")
if not isinstance(pos, int) or pos < 0:
return None
if isinstance(ts, str):
try:
ts = datetime.fromisoformat(ts)
except ValueError, TypeError:
return None
elif isinstance(ts, datetime):
pass
else:
return None
return _PlaybackEntry(pos=pos, ts=ts)
@dataclass
class _PlaybackRootSnapshot:
file_path: Path
signature: tuple[bool, int, int] | None = None
entries: dict[str, _PlaybackEntry] = field(default_factory=dict)
class PlaybackStateCache:
"""Background cache for merged playback-state across all active roots.
Stores resume positions by movie slug with timestamps. When merging
across roots, picks the most recent entry for each slug.
"""
def __init__(self, poll_interval: float = 60.0) -> None:
self._poll_interval = poll_interval
self._roots: dict[str, _PlaybackRootSnapshot] = {}
self._merged_entries: dict[str, _PlaybackEntry] = {}
self._lock = threading.RLock()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(
target=self._run,
name="mediahive-playback-state-cache",
daemon=True,
)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=max(2.0, self._poll_interval + 1.0))
self._thread = None
def get_merged_entries(self) -> dict[str, _PlaybackEntry]:
"""Return merged resume entries keyed by slug (most recent wins)."""
with self._lock:
return {
slug: _PlaybackEntry(e.pos, e.ts)
for slug, e in self._merged_entries.items()
}
def update_resume_position(
self,
root_id: str,
root_path: Path,
slug: str,
pos: int | None,
) -> None:
"""Read-modify-write one root file and refresh the in-memory cache immediately."""
file_path = root_path / ".mediahive" / "playback-state.json"
entries = self._read_resume_entries(file_path)
if pos is None:
entries.pop(slug, None)
else:
entries[slug] = _PlaybackEntry(pos=pos, ts=datetime.now())
self._write_resume_entries(file_path, entries)
snapshot = _PlaybackRootSnapshot(
file_path=file_path,
signature=self._signature(file_path),
entries=entries,
)
with self._lock:
self._roots[root_id] = snapshot
self._merged_entries = self._build_merged_entries(self._roots)
def _run(self) -> None:
while not self._stop.is_set():
try:
self._refresh_once()
except Exception:
logger.exception("Playback-state cache refresh failed")
if self._stop.wait(self._poll_interval):
break
def _refresh_once(self) -> None:
contexts = supervisor.all_contexts()
with self._lock:
previous = self._roots
next_roots: dict[str, _PlaybackRootSnapshot] = {}
merged: dict[str, _PlaybackEntry] = {}
for root_id, ctx in contexts.items():
file_path = ctx.root_path / ".mediahive" / "playback-state.json"
snapshot = previous.get(root_id)
if snapshot is None or snapshot.file_path != file_path:
snapshot = _PlaybackRootSnapshot(file_path=file_path)
signature = self._signature(file_path)
if signature != snapshot.signature:
snapshot.signature = signature
snapshot.entries = self._read_resume_entries(file_path)
next_roots[root_id] = snapshot
# Merge: for each slug, keep the entry with the most recent timestamp
for slug, entry in snapshot.entries.items():
existing = merged.get(slug)
if existing is None or entry.ts > existing.ts:
merged[slug] = entry
with self._lock:
self._roots = next_roots
self._merged_entries = merged
@staticmethod
def _signature(path: Path) -> tuple[bool, int, int]:
try:
stat = path.stat()
return (True, stat.st_mtime_ns, stat.st_size)
except OSError:
return (False, 0, 0)
@staticmethod
def _read_resume_entries(path: Path) -> dict[str, _PlaybackEntry]:
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except OSError, json.JSONDecodeError:
return {}
if not isinstance(raw, dict):
return {}
positions = raw.get("resume_positions")
if not isinstance(positions, dict):
return {}
entries: dict[str, _PlaybackEntry] = {}
for slug, data in positions.items():
entry = _PlaybackEntry.from_dict(data)
if entry is not None:
entries[str(slug)] = entry
return entries
@staticmethod
def _write_resume_entries(path: Path, entries: dict[str, _PlaybackEntry]) -> None:
data = {
"resume_positions": {
slug: entry.to_dict() for slug, entry in sorted(entries.items())
}
}
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
tmp_path.write_text(
json.dumps(data, indent=2, sort_keys=True, default=str),
encoding="utf-8",
)
tmp_path.replace(path)
except OSError:
logger.exception("Failed to write playback-state: %s", path)
@staticmethod
def _build_merged_entries(
roots: dict[str, _PlaybackRootSnapshot],
) -> dict[str, _PlaybackEntry]:
merged: dict[str, _PlaybackEntry] = {}
for snapshot in roots.values():
for slug, entry in snapshot.entries.items():
existing = merged.get(slug)
if existing is None or entry.ts > existing.ts:
merged[slug] = entry
return merged
class EventLoopLagMonitor:
"""Tracks event-loop scheduling lag over a sliding window."""
def __init__(
self, sample_interval: float = 0.05, window_seconds: float = 10.0
) -> None:
self._sample_interval = sample_interval
self._window_seconds = window_seconds
self._task: asyncio.Task | None = None
self._last_lag_ms = 0.0
self._samples: deque[tuple[float, float]] = deque()
def start(self) -> None:
if self._task is not None and not self._task.done():
return
self._task = asyncio.create_task(self._run(), name="mediahive-event-loop-lag")
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
with suppress(asyncio.CancelledError):
await self._task
self._task = None
def snapshot(self) -> tuple[float, float]:
now = time.perf_counter()
self._prune(now)
max_window_ms = max((lag for _, lag in self._samples), default=0.0)
return self._last_lag_ms, max_window_ms
async def _run(self) -> None:
interval = self._sample_interval
next_tick = time.perf_counter() + interval
while True:
await asyncio.sleep(interval)
now = time.perf_counter()
lag_ms = max(0.0, (now - next_tick) * 1000.0)
self._last_lag_ms = lag_ms
self._samples.append((now, lag_ms))
self._prune(now)
next_tick = now + interval
def _prune(self, now: float) -> None:
cutoff = now - self._window_seconds
while self._samples and self._samples[0][0] < cutoff:
self._samples.popleft()
playback_state_cache = PlaybackStateCache()
event_loop_lag_monitor = EventLoopLagMonitor()
# ---------------------------------------------------------------------------
# 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 _normalize_media_path_value(path: str) -> str:
return path.replace("\\", "/").lstrip("/")
def _expand_torrent_playable_path(file_key: str, playable_file: str | None) -> str:
if not playable_file:
return file_key
if playable_file.startswith("concat:") or "://" in playable_file:
return playable_file
if playable_file.startswith(f"{file_key}/"):
return playable_file
if playable_file.startswith("/"):
return playable_file.lstrip("/")
return f"{file_key}/{playable_file}"
def _resolve_movie_slug_for_file_path(ctx, file_path: str) -> str | None:
target = _normalize_media_path_value(file_path)
for movie_id, movie in ctx.store.movies.items():
for file_key, torrent in movie.files.items():
normalized_key = _normalize_media_path_value(file_key)
if normalized_key == target:
return movie_id
playable_path = _expand_torrent_playable_path(
file_key, torrent.playable_file
)
if _normalize_media_path_value(playable_path) == target:
return movie_id
return None
def _load_root_metadata(root_path: Path, meta_key: str):
"""Load allowed per-root metadata values from .mediahive."""
key = meta_key.strip().lower().strip("/")
allowed: dict[str, tuple[str, str]] = {
"playback-state": ("playback-state.json", "json"),
"scanignore": ("scanignore", "text"),
}
mapped = allowed.get(key)
if mapped is None:
raise HTTPException(status_code=404, detail=f"Unknown metadata key: {meta_key}")
rel_path, mode = mapped
full_path = _resolve_root_scoped_path(root_path / ".mediahive", rel_path)
if not full_path.exists() or not full_path.is_file():
raise HTTPException(status_code=404, detail=f"Metadata not found: {meta_key}")
try:
if mode == "json":
return json.loads(full_path.read_text(encoding="utf-8"))
return full_path.read_text(encoding="utf-8")
except OSError, TypeError, json.JSONDecodeError:
raise HTTPException(
status_code=500, detail=f"Failed to load metadata: {meta_key}"
)
def _all_root_statuses() -> list[WsRootStatus]:
"""Return root statuses in WebSocket wire format."""
return [
WsRootStatus(
root_id=s["root_id"],
path=s["path"],
status=s["status"],
error=s.get("error"),
snapshot_loaded=bool(s.get("snapshot_loaded")),
movies=int(s.get("movies", 0)),
series=int(s.get("series", 0)),
)
for s in supervisor.all_statuses()
]
def _full_ws_init(root_ids: set[str] | None = None) -> WsInit:
"""Build an init payload for all roots or only selected root_ids."""
roots: dict[str, WsRootInitData] = {}
for rid, ctx in supervisor.all_contexts().items():
if root_ids is not None and rid not in root_ids:
continue
roots[rid] = WsRootInitData(
movies=dict(ctx.store.movies),
series=dict(ctx.store.series),
people=dict(ctx.store.people),
)
return WsInit(roots=roots)
def _translate_store_event(root_id: str, event: object):
"""Convert per-root store events into unified websocket messages."""
if isinstance(event, Upsert):
return WsUpsert(
root_id=root_id,
kind=event.kind,
id=event.id,
item=event.item,
people=event.people,
)
if isinstance(event, Remove):
return WsRemove(root_id=root_id, kind=event.kind, id=event.id)
if isinstance(event, Task):
return WsTask(root_id=root_id, data=event.data)
return None
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 _build_file_etag(file_size: int, mtime_ns: int) -> str:
"""Build a weak ETag from file metadata for cache validation."""
return f'W/"{file_size:x}-{mtime_ns:x}"'
def _etag_matches_if_none_match(if_none_match: str | None, etag: str) -> bool:
"""Return True when the request If-None-Match header matches the resource ETag."""
if not if_none_match:
return False
if if_none_match.strip() == "*":
return True
def normalize(value: str) -> str:
v = value.strip()
if v.startswith("W/"):
v = v[2:].strip()
return v
wanted = normalize(etag)
return any(normalize(candidate) == wanted for candidate in if_none_match.split(","))
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."""
async with _attach_scanners_lock:
for ctx in supervisor.all_contexts().values():
if ctx.scanner is None and ctx.status == "ready":
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 _attach_scanners_loop() -> None:
"""Periodically attach scanners as roots transition to ready."""
while True:
await _attach_scanners()
await asyncio.sleep(1)
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/config/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()
playback_state_cache.start()
event_loop_lag_monitor.start()
# 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())
scanner_attach_task = asyncio.create_task(_attach_scanners_loop())
logger.info("Server ready; waiting for root activation")
try:
yield
finally:
playback_state_cache.stop()
await event_loop_lag_monitor.stop()
activation_task.cancel()
with suppress(asyncio.CancelledError):
await activation_task
scanner_attach_task.cancel()
with suppress(asyncio.CancelledError):
await scanner_attach_task
await supervisor.shutdown()
with suppress(Exception):
await close_http_client()
with suppress(Exception):
await close_image_client()
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=DEVMODE)
# Custom access logging (uvicorn access logs are suppressed in access_logging)
app.add_middleware(AccessLogMiddleware)
# 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.put("/api/config/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": [{"path": e.path, "root_id": e.root_id} for e in accepted],
"failed": failed,
}
# --- Unified WebSocket ---
@app.websocket("/api/ws")
async def ws_endpoint(ws: WebSocket) -> None:
"""Live updates stream for all roots and all connected clients."""
listeners: dict[str, object] = {}
attached_contexts = supervisor.all_contexts()
outbound: asyncio.Queue[bytes] = asyncio.Queue()
start = time.perf_counter()
ws_id = log_ws_open(ws)
close_code: int | None = None
prev_root_ids: set[str] = set()
prev_meta: dict[str, tuple[str, str, str | None, bool]] = {}
def _sync_listeners() -> tuple[
set[str], dict[str, tuple[str, str, str | None, bool]]
]:
nonlocal attached_contexts
current_contexts = supervisor.all_contexts()
current_ids = set(current_contexts.keys())
for rid in list(listeners.keys()):
if rid in current_ids:
continue
old_ctx = attached_contexts.get(rid)
listener = listeners.pop(rid)
if old_ctx is not None:
old_ctx.store.remove_listener(listener)
for rid, ctx in current_contexts.items():
if rid in listeners:
continue
def _listener(event: object, *, _rid=rid) -> None:
translated = _translate_store_event(_rid, event)
if translated is None:
return
outbound.put_nowait(msgspec.json.encode(translated))
listeners[rid] = _listener
ctx.store.add_listener(_listener)
attached_contexts = current_contexts
meta = {
s.root_id: (s.path, s.status, s.error, s.snapshot_loaded)
for s in _all_root_statuses()
}
return current_ids, meta
async def _send_outbound() -> None:
while True:
payload = await outbound.get()
await ws.send_bytes(payload)
async def _watch_roots() -> None:
nonlocal prev_root_ids, prev_meta
while True:
current_ids, current_meta = _sync_listeners()
root_set_changed = current_ids != prev_root_ids
meta_changed = current_meta != prev_meta
if root_set_changed or meta_changed:
outbound.put_nowait(
msgspec.json.encode(WsRoots(roots=_all_root_statuses()))
)
if root_set_changed:
outbound.put_nowait(msgspec.json.encode(_full_ws_init()))
else:
became_loaded = {
rid
for rid, meta in current_meta.items()
if rid in prev_meta and not prev_meta[rid][3] and meta[3]
}
if became_loaded:
outbound.put_nowait(
msgspec.json.encode(_full_ws_init(became_loaded))
)
prev_root_ids = current_ids
prev_meta = current_meta
await asyncio.sleep(1.0)
await ws.accept()
current_ids, current_meta = _sync_listeners()
prev_root_ids = current_ids
prev_meta = current_meta
await ws.send_bytes(msgspec.json.encode(WsRoots(roots=_all_root_statuses())))
await ws.send_bytes(msgspec.json.encode(_full_ws_init()))
sender_task = asyncio.create_task(_send_outbound())
watcher_task = asyncio.create_task(_watch_roots())
try:
while True:
await ws.receive_text()
except WebSocketDisconnect as exc:
close_code = exc.code
except OSError, RuntimeError:
pass
finally:
sender_task.cancel()
watcher_task.cancel()
with suppress(asyncio.CancelledError):
await sender_task
with suppress(asyncio.CancelledError):
await watcher_task
for rid, listener in list(listeners.items()):
ctx = attached_contexts.get(rid)
if ctx is not None:
ctx.store.remove_listener(listener)
log_ws_close(ws_id, close_code, time.perf_counter() - start)
# --- Media actions ---
@app.get("/api/players")
async def list_players():
"""Return detected media players available on this system."""
players = detect_players()
return {"players": [msgspec.structs.asdict(p) for p in players]}
@app.post("/api/play/{root_id}")
async def play_media(root_id: str, request: Request, response: Response):
"""Open a media file with the selected player."""
req_start = time.perf_counter()
trace_id = request.headers.get("x-mediahive-trace-id", "")
client_sent_ms_hdr = request.headers.get("x-mediahive-client-sent-ms")
client_to_server_ms: float | None = None
if client_sent_ms_hdr:
with suppress(ValueError):
client_to_server_ms = max(
0.0, (time.time() * 1000.0) - float(client_sent_ms_hdr)
)
ctx = _get_context(root_id)
body_t0 = time.perf_counter()
req = msgspec.json.decode(await request.body(), type=PlayMediaRequest)
decode_ms = (time.perf_counter() - body_t0) * 1000.0
resolve_t0 = time.perf_counter()
file_path = _resolve_root_scoped_path(ctx.root_path, req.file_path)
resolve_ms = (time.perf_counter() - resolve_t0) * 1000.0
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"File not found: {req.file_path}")
# Resolve player path if a specific detected player was chosen
player_path: str | None = None
detect_ms = 0.0
if req.player_id and req.player_id not in ("default", "custom"):
detect_t0 = time.perf_counter()
for p in detect_players():
if p.id == req.player_id:
player_path = p.path
break
detect_ms = (time.perf_counter() - detect_t0) * 1000.0
if not player_path:
raise HTTPException(
status_code=400, detail=f"Player not found: {req.player_id}"
)
try:
launch_t0 = time.perf_counter()
launch_player(
req.player_id or "default",
file_path,
player_path=player_path,
custom_cmd=req.player_custom_cmd,
)
launch_ms = (time.perf_counter() - launch_t0) * 1000.0
total_ms = (time.perf_counter() - req_start) * 1000.0
loop_lag_ms, loop_lag_max_ms = event_loop_lag_monitor.snapshot()
if trace_id:
response.headers["X-MediaHive-Trace-Id"] = trace_id
response.headers["Server-Timing"] = (
f"app;dur={total_ms:.1f},"
f"decode;dur={decode_ms:.1f},"
f"resolve;dur={resolve_ms:.1f},"
f"detect;dur={detect_ms:.1f},"
f"launch;dur={launch_ms:.1f},"
f"looplag;dur={loop_lag_ms:.1f},"
f"looplagmax;dur={loop_lag_max_ms:.1f}"
)
request.state.log_extra = (
f"trace={trace_id or '-'} "
f"phase[decode={decode_ms:.1f}ms resolve={resolve_ms:.1f}ms "
f"detect={detect_ms:.1f}ms launch={launch_ms:.1f}ms] "
f"loopLag={loop_lag_ms:.1f}/{loop_lag_max_ms:.1f}ms"
)
if client_to_server_ms is not None:
request.state.log_extra = (
f"{request.state.log_extra} clientToServer={client_to_server_ms:.1f}ms"
)
return {"status": "ok"}
except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e:
raise HTTPException(status_code=500, detail=f"Failed to play media: {e}")
@app.post("/api/open-folder/{root_id}")
async def open_folder(root_id: str, request: Request, response: Response):
"""Open a folder in the system file explorer."""
req_start = time.perf_counter()
trace_id = request.headers.get("x-mediahive-trace-id", "")
client_sent_ms_hdr = request.headers.get("x-mediahive-client-sent-ms")
client_to_server_ms: float | None = None
if client_sent_ms_hdr:
with suppress(ValueError):
client_to_server_ms = max(
0.0, (time.time() * 1000.0) - float(client_sent_ms_hdr)
)
ctx = _get_context(root_id)
body_t0 = time.perf_counter()
req = msgspec.json.decode(await request.body(), type=OpenFolderRequest)
decode_ms = (time.perf_counter() - body_t0) * 1000.0
resolve_t0 = time.perf_counter()
target_path = _resolve_root_scoped_path(ctx.root_path, req.folder_path)
resolve_ms = (time.perf_counter() - resolve_t0) * 1000.0
if not target_path.exists():
raise HTTPException(
status_code=404, detail=f"Path not found: {req.folder_path}"
)
try:
open_t0 = time.perf_counter()
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)])
open_ms = (time.perf_counter() - open_t0) * 1000.0
total_ms = (time.perf_counter() - req_start) * 1000.0
loop_lag_ms, loop_lag_max_ms = event_loop_lag_monitor.snapshot()
if trace_id:
response.headers["X-MediaHive-Trace-Id"] = trace_id
response.headers["Server-Timing"] = (
f"app;dur={total_ms:.1f},"
f"decode;dur={decode_ms:.1f},"
f"resolve;dur={resolve_ms:.1f},"
f"open;dur={open_ms:.1f},"
f"looplag;dur={loop_lag_ms:.1f},"
f"looplagmax;dur={loop_lag_max_ms:.1f}"
)
request.state.log_extra = (
f"trace={trace_id or '-'} "
f"phase[decode={decode_ms:.1f}ms resolve={resolve_ms:.1f}ms open={open_ms:.1f}ms] "
f"loopLag={loop_lag_ms:.1f}/{loop_lag_max_ms:.1f}ms"
)
if client_to_server_ms is not None:
request.state.log_extra = (
f"{request.state.log_extra} clientToServer={client_to_server_ms:.1f}ms"
)
return {"status": "ok"}
except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e:
raise HTTPException(status_code=500, detail=f"Failed to open folder: {e}")
@app.get("/api/meta/{root_id}/{meta_key}")
async def root_metadata(root_id: str, meta_key: str):
"""Return a root metadata value from .mediahive for allowed keys."""
ctx = _get_context(root_id)
return {"key": meta_key, "data": _load_root_metadata(ctx.root_path, meta_key)}
@app.get("/api/meta/playback-state")
async def merged_playback_state():
"""Return merged playback-state resume positions from in-memory cache.
Merges across all roots, preferring the most recent timestamp for each slug.
Format: {"key": "playback-state", "data": {"resume_positions": {slug: {pos, ts}}}}
"""
entries = playback_state_cache.get_merged_entries()
positions = {slug: entry.to_dict() for slug, entry in entries.items()}
return {"key": "playback-state", "data": {"resume_positions": positions}}
@app.post("/api/meta/playback-state")
async def write_playback_state(request: Request):
"""Update one playback-state entry via backend-managed read-modify-write."""
req = msgspec.json.decode(await request.body(), type=PlaybackStateUpdateRequest)
ctx = _get_context(req.root_id)
slug = _resolve_movie_slug_for_file_path(ctx, req.file_path)
if slug is None:
raise HTTPException(
status_code=404,
detail=f"Movie not found for file path: {req.file_path}",
)
pos = None if req.pos is None or req.pos <= 0 else int(req.pos)
playback_state_cache.update_resume_position(req.root_id, ctx.root_path, slug, pos)
return {"status": "ok", "slug": slug, "pos": pos}
# --- MPC-BE / Player status ---
def _mpcbe_url(port: int | None = None) -> str:
"""Build MPC-BE base URL from optional custom port."""
return f"http://127.0.0.1:{port or MPC_BE_DEFAULT_PORT}"
@app.get("/api/mpcbe/status")
async def mpcbe_status(port: int | None = None):
"""Check whether MPC-BE web interface is reachable."""
return {"reachable": _mpcbe_request("/", port=port)}
@app.get("/api/player/status")
async def player_status(port: int | None = None):
"""Return whether remote player control is currently available."""
return {"remote": _mpcbe_request("/", port=port)}
def _mpcbe_request(path: str, timeout: float = 0.75, port: int | None = None) -> bool:
"""Call MPC-BE's local web interface and return True on HTTP success."""
if sys.platform != "win32":
return False
url = f"{_mpcbe_url(port)}{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 ---
def _resolve_root_scoped_path(base: Path, raw_path: str) -> Path:
"""Resolve a user path under a fixed base directory and block traversal."""
candidate = base / raw_path.lstrip("/")
try:
candidate.resolve().relative_to(base.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
return candidate
def _serve_file_response(full_path: Path, file_path: str, request: Request):
"""Serve a file with range + cache support."""
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_stat = full_path.stat()
file_size = file_stat.st_size
etag = _build_file_etag(file_size, file_stat.st_mtime_ns)
cache_control = "public, max-age=604800, immutable"
range_header = request.headers.get("range")
if not range_header and _etag_matches_if_none_match(
request.headers.get("if-none-match"), etag
):
return Response(
status_code=304,
headers={"Cache-Control": cache_control, "ETag": etag},
)
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": cache_control, "ETag": etag},
)
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": cache_control,
"ETag": etag,
"Accept-Ranges": "bytes",
}
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,
)
@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 = _resolve_root_scoped_path(ctx.root_path, file_path)
return _serve_file_response(full_path, file_path, request)
@app.get("/api/assets/{root_id}/{asset_type}/{asset_path:path}")
async def serve_root_asset_file(
root_id: str,
asset_type: str,
asset_path: str,
request: Request,
):
"""Serve typed files from a root's .mediahive cache using logical asset paths."""
asset_type_key = asset_type.lower()
if asset_type_key not in _ROOT_ASSET_TYPES:
raise HTTPException(status_code=404, detail=f"Unknown asset type: {asset_type}")
ctx = _get_context(root_id)
base = ctx.root_path / ".mediahive"
logical_path = f"{asset_type_key}/{asset_path}"
full_path = _resolve_root_scoped_path(base, logical_path)
return _serve_file_response(full_path, logical_path, request)
# Serve the Vue frontend (needs to be last if SPA catch-all is used)
frontend.route(app, "/")