552 lines
17 KiB
Python
552 lines
17 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).
|
|
"""
|
|
|
|
import asyncio
|
|
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
|
|
import msgspec.structs
|
|
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, save_config
|
|
from mediahive.hivescan.scanner import start as start_scanner
|
|
from mediahive.hivescan.scanner import stop as stop_scanner
|
|
from mediahive.index_store import IndexStore
|
|
from mediahive.models.events import ScanEvent, Task, Upsert
|
|
from mediahive.models.protocol import (
|
|
ChangeFolderRequest,
|
|
MsgspecResponse,
|
|
OpenFolderRequest,
|
|
PlayMediaRequest,
|
|
StatusResponse,
|
|
)
|
|
|
|
logger = logging.getLogger("mediahive.server")
|
|
|
|
MPC_BE_BASE_URL = "http://127.0.0.1:13579"
|
|
|
|
# Suppress console windows when spawning subprocesses on Windows
|
|
_POPEN_KWARGS: dict = (
|
|
{"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {}
|
|
)
|
|
|
|
# Vue Frontend static files
|
|
frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"])
|
|
|
|
# Media root path (initialized in lifespan)
|
|
MEDIAROOT = None
|
|
|
|
# In-memory index store (available immediately, switched to real root later)
|
|
_BOOTSTRAP_SNAPSHOT = Path(tempfile.gettempdir()) / "mediahive" / "index.json"
|
|
store: IndexStore = IndexStore(_BOOTSTRAP_SNAPSHOT, media_root=None)
|
|
|
|
# Whether the scanner subsystem is active
|
|
_scanner_active = False
|
|
|
|
# Background folder switch task and lock so startup/change-folder cannot race
|
|
_folder_switch_task: asyncio.Task | None = None
|
|
_folder_switch_lock = asyncio.Lock()
|
|
|
|
# Queue for scanner → server events
|
|
_scan_events: asyncio.Queue[ScanEvent] = asyncio.Queue()
|
|
_consumer_task: asyncio.Task | None = None
|
|
_RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
|
|
|
|
|
|
def _require_media_root() -> Path:
|
|
if MEDIAROOT is None:
|
|
raise HTTPException(status_code=503, detail="Media root not initialized yet")
|
|
return MEDIAROOT
|
|
|
|
|
|
async def _send_event(event: ScanEvent) -> None:
|
|
"""Push a scan event onto the queue (passed to hivescan as *send*)."""
|
|
await _scan_events.put(event)
|
|
|
|
|
|
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)
|
|
|
|
|
|
async def _consume_scan_events() -> None:
|
|
"""Background task: apply incoming scan events to the IndexStore."""
|
|
while True:
|
|
try:
|
|
event = await _scan_events.get()
|
|
if isinstance(event, Upsert):
|
|
if event.kind == "movie":
|
|
store.upsert_movie(event.item)
|
|
else:
|
|
store.upsert_series(event.item)
|
|
elif isinstance(event, Task):
|
|
store.broadcast_task(event.data)
|
|
except asyncio.CancelledError:
|
|
return
|
|
except Exception:
|
|
logger.exception("Error processing scan event")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
global MEDIAROOT, store, _scanner_active, _consumer_task, _folder_switch_task
|
|
|
|
await frontend.load()
|
|
|
|
# Bring up the API immediately with an empty in-memory store.
|
|
# Media-root initialization/scanner startup are deferred to a background task
|
|
# so macOS permission prompts cannot block server readiness.
|
|
store = IndexStore(_BOOTSTRAP_SNAPSHOT, media_root=None)
|
|
await store.load_snapshot()
|
|
_scanner_active = False
|
|
|
|
logger.info(
|
|
"Server started without active media root; waiting for folder activation"
|
|
)
|
|
|
|
yield
|
|
|
|
# Shutdown
|
|
if _folder_switch_task and not _folder_switch_task.done():
|
|
_folder_switch_task.cancel()
|
|
try:
|
|
await _folder_switch_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
await stop_scanner()
|
|
_scanner_active = False
|
|
if _consumer_task:
|
|
_consumer_task.cancel()
|
|
try:
|
|
await _consumer_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
await store.flush_snapshot()
|
|
|
|
|
|
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=["*"],
|
|
)
|
|
|
|
|
|
def normalize_path(url_path: str) -> Path:
|
|
"""
|
|
Convert URL path to filesystem path.
|
|
URL: /media/.mediahive/Movies/...
|
|
Returns: MEDIAROOT/.mediahive/Movies/...
|
|
"""
|
|
clean_path = url_path.lstrip("/")
|
|
return _require_media_root() / clean_path
|
|
|
|
|
|
def _load_resume_positions() -> dict[str, int]:
|
|
playback_state_path = _require_media_root() / ".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)
|
|
|
|
|
|
# === 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."""
|
|
return {"media_folder": str(MEDIAROOT) if MEDIAROOT else None}
|
|
|
|
|
|
@app.post("/api/change-folder")
|
|
async def change_folder_endpoint(request: Request):
|
|
"""Switch the media root folder without restarting the server.
|
|
|
|
Validates and persists the new folder, then returns immediately.
|
|
The actual in-memory switch runs as a background task so the HTTP
|
|
response is not held up by the (potentially slow) scanner teardown.
|
|
The client should poll /api/config or reload after a short delay.
|
|
"""
|
|
body = msgspec.json.decode(await request.body(), type=ChangeFolderRequest)
|
|
new_root = Path(body.folder).resolve()
|
|
if not new_root.exists() or not new_root.is_dir():
|
|
raise HTTPException(
|
|
status_code=400, detail=f"Folder does not exist: {new_root}"
|
|
)
|
|
|
|
# Persist first — if the background switch crashes, the next launch still uses the new path
|
|
cfg = load_config()
|
|
save_config(msgspec.structs.replace(cfg, media_folder=str(new_root)))
|
|
logger.info("Config saved: media_folder=%s", new_root)
|
|
|
|
# Schedule the in-memory switch without blocking this response
|
|
asyncio.create_task(_switch_folder(new_root))
|
|
return {"status": "ok"}
|
|
|
|
|
|
async def _switch_folder(new_root: Path) -> None:
|
|
global MEDIAROOT, store, _consumer_task, _scan_events, _scanner_active
|
|
|
|
async with _folder_switch_lock:
|
|
try:
|
|
# Cancel scanner tasks immediately — no need to wait 30 s
|
|
await stop_scanner()
|
|
_scanner_active = False
|
|
|
|
# Tear down the old event consumer
|
|
if _consumer_task and not _consumer_task.done():
|
|
_consumer_task.cancel()
|
|
try:
|
|
await _consumer_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
# Flush the old index snapshot
|
|
await store.flush_snapshot()
|
|
|
|
# Update env and module globals
|
|
os.environ["MEDIAHIVE_PATH"] = str(new_root)
|
|
MEDIAROOT = new_root
|
|
|
|
# Fresh event queue — discard any stale events from the old folder
|
|
_scan_events = asyncio.Queue()
|
|
|
|
# Re-initialise the index store
|
|
snapshot_path = MEDIAROOT / ".mediahive" / "index.json"
|
|
store = IndexStore(snapshot_path, media_root=str(MEDIAROOT))
|
|
await store.load_snapshot()
|
|
logger.info(
|
|
"Index store ready: %d movies, %d series",
|
|
len(store.movies),
|
|
len(store.series),
|
|
)
|
|
|
|
# Restart consumer and scanner
|
|
_consumer_task = asyncio.create_task(_consume_scan_events())
|
|
await start_scanner(_send_event)
|
|
_scanner_active = True
|
|
|
|
logger.info("Switched media folder to %s", MEDIAROOT)
|
|
except Exception:
|
|
logger.exception("Error switching media folder to %s", new_root)
|
|
|
|
|
|
@app.get("/api/index")
|
|
async def get_index():
|
|
"""Return the full media index from the in-memory store."""
|
|
return MsgspecResponse(store.get_full_index())
|
|
|
|
|
|
@app.get("/api/playback/resume-positions")
|
|
async def playback_resume_positions():
|
|
"""Return saved per-file resume positions under the current media root."""
|
|
return {"resume_positions": _load_resume_positions()}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scanning API (active when HIVESCAN_PATHS is configured)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@app.websocket("/api/ws")
|
|
async def ws_endpoint(ws: WebSocket):
|
|
"""Live index updates and task progress."""
|
|
await store.connect(ws)
|
|
try:
|
|
while True:
|
|
await ws.receive_text()
|
|
except WebSocketDisconnect:
|
|
store.disconnect(ws)
|
|
except Exception:
|
|
store.disconnect(ws)
|
|
|
|
|
|
@app.post("/api/scan")
|
|
async def trigger_scan():
|
|
"""Trigger a new scan. Returns 409 if a scan is already running."""
|
|
if not _scanner_active:
|
|
raise HTTPException(status_code=503, detail="Scanner not active yet")
|
|
from mediahive.hivescan.scanner import trigger_scan as _trigger
|
|
|
|
started = _trigger()
|
|
return {"status": "started" if started else "already_running"}
|
|
|
|
|
|
@app.get("/api/status")
|
|
async def server_status():
|
|
"""Return current server status."""
|
|
if _scanner_active:
|
|
from mediahive.hivescan.scanner import is_scanning, showreel_queue_size
|
|
|
|
return MsgspecResponse(
|
|
StatusResponse(
|
|
scanning=is_scanning(),
|
|
movies=len(store.movies),
|
|
series=len(store.series),
|
|
showreel_queue=showreel_queue_size(),
|
|
)
|
|
)
|
|
return MsgspecResponse(
|
|
StatusResponse(
|
|
movies=len(store.movies),
|
|
series=len(store.series),
|
|
)
|
|
)
|
|
|
|
|
|
@app.post("/api/play")
|
|
async def play_media(request: Request):
|
|
"""
|
|
Open a media file with the system's default player.
|
|
"""
|
|
req = msgspec.json.decode(await request.body(), type=PlayMediaRequest)
|
|
print(f"[play] Received path: {req.file_path}")
|
|
file_path = _require_media_root() / req.file_path
|
|
|
|
if not file_path.exists():
|
|
print(f"[play] File not found: {file_path}")
|
|
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/open-folder")
|
|
async def open_folder(request: Request):
|
|
"""
|
|
Open a folder in the system file explorer.
|
|
If the path is a file, opens the parent folder and selects the file.
|
|
"""
|
|
req = msgspec.json.decode(await request.body(), type=OpenFolderRequest)
|
|
print(f"[open-folder] Received path: {req.folder_path}")
|
|
target_path = _require_media_root() / req.folder_path
|
|
|
|
if not target_path.exists():
|
|
print(f"[open-folder] Path not found: {target_path}")
|
|
raise HTTPException(
|
|
status_code=404, detail=f"Path not found: {req.folder_path}"
|
|
)
|
|
|
|
try:
|
|
if sys.platform == "win32":
|
|
if target_path.is_file():
|
|
# Open parent folder and select the file
|
|
subprocess.Popen(
|
|
["explorer", "/select,", str(target_path)], **_POPEN_KWARGS
|
|
)
|
|
else:
|
|
# Open the folder directly
|
|
subprocess.Popen(["explorer", str(target_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:
|
|
# Linux - just open the folder (no standard way to select)
|
|
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}")
|
|
|
|
|
|
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
|
|
|
|
|
|
@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("/")}
|
|
|
|
|
|
@app.get("/api/media/{file_path:path}")
|
|
async def serve_media_file(file_path: str, request: Request):
|
|
"""
|
|
Serve a media file asynchronously.
|
|
"""
|
|
full_path = normalize_path(file_path)
|
|
media_root = _require_media_root()
|
|
|
|
# Security: ensure path doesn't escape base
|
|
try:
|
|
full_path.resolve().relative_to(media_root.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
|
|
|
|
# Guess content type
|
|
content_type, _ = mimetypes.guess_type(str(full_path))
|
|
if content_type is None:
|
|
content_type = "application/octet-stream"
|
|
|
|
# For images, use FileResponse which handles caching headers
|
|
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, "/")
|