1288 lines
42 KiB
Python
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, "/")
|