Refactor API to flat root routes and WS/meta structure

This commit is contained in:
2026-05-30 02:09:24 +00:00
parent 72963aeb7a
commit 0ef6896181
7 changed files with 82 additions and 83 deletions
+8 -9
View File
@@ -11,22 +11,21 @@ All media paths are scoped to a **root**, identified by a stable `root_id`.
| `GET` | `/api/config` | Returns the current root configuration. |
| `GET` | `/api/roots` | List all active roots with status. |
| `PUT` | `/api/roots` | Atomically replace the full root set. |
| `GET` | `/api/roots/{root_id}/status` | Returns scanner and library status for one root. |
| `POST` | `/api/roots/{root_id}/scan` | Triggers a new scan for one root. |
| `POST` | `/api/roots/{root_id}/play` | Opens a media file with the system player. |
| `POST` | `/api/roots/{root_id}/open-folder` | Opens a folder in the system file explorer. |
| `GET` | `/api/roots/{root_id}/playback/resume-positions` | Returns saved resume positions for one root. |
| `POST` | `/api/play/{root_id}` | Opens a media file with the system player. |
| `POST` | `/api/open-folder/{root_id}` | Opens a folder in the system file explorer. |
| `GET` | `/api/meta/{root_id}/{meta_key}` | Returns allowed metadata from `<root>/.mediahive`. |
| `GET` | `/api/player/status` | Returns whether remote player control is currently available. |
| `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable. |
| `GET` | `/api/media/{root_id}/{file_path:path}` | Serves files from the specified root. |
| `GET` | `/api/roots/{root_id}/assets/{asset_path:path}` | Serves files from `<root>/.mediahive` via logical asset paths. |
| `WS` | `/api/roots/{root_id}/ws` | Streams live index updates and task progress for one root. |
| `GET` | `/api/assets/{root_id}/{asset_type}/{asset_path:path}` | Serves typed assets from `<root>/.mediahive`. |
| `WS` | `/api/ws/{root_id}` | Streams live index updates and task progress for one root. |
## Notes
- `PUT /api/roots` accepts `{ "roots": { "name": "/absolute/path", ... } }`, validates paths, and atomically swaps the active set.
- `POST /api/roots/{root_id}/play` and `POST /api/roots/{root_id}/open-folder` expect JSON request bodies with `file_path` / `folder_path` relative to the root.
- `POST /api/play/{root_id}` and `POST /api/open-folder/{root_id}` expect JSON request bodies with `file_path` / `folder_path` relative to the root.
- `GET /api/media/{root_id}/{file_path:path}` is constrained to the specified root; path traversal outside the root is rejected.
- `GET /api/roots/{root_id}/assets/{asset_path:path}` is constrained to `<root>/.mediahive`; metadata image URLs should use this endpoint.
- `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` is constrained to `<root>/.mediahive/{asset_type}` where `asset_type` is one of `movies`, `series`, `people`.
- `GET /api/meta/{root_id}/{meta_key}` supports metadata keys currently limited to `playback-state` and `scanignore`.
- `GET /api/player/status` returns `{ "remote": true|false }`.
- `GET /api/mpcbe/status` returns `false` on non-Windows platforms.
+6 -8
View File
@@ -47,14 +47,12 @@ Each active root gets an isolated `RootContext` managed by the `Supervisor`:
|----------|-------------|
| `GET /api/roots` | List all roots (name, path, root_id, status) |
| `PUT /api/roots` | Atomically replace full root map `{name: path}` |
| `GET /api/roots/{root_id}/status` | Per-root scanning/loading/error state |
| `POST /api/roots/{root_id}/scan` | Trigger scan for one root |
| `WS /api/roots/{root_id}/ws` | Per-root WebSocket (init/upsert/remove/task) |
| `WS /api/ws/{root_id}` | Per-root WebSocket (init/upsert/remove/task + status/task events) |
| `GET /api/media/{root_id}/{path:path}` | Serve media file scoped to root |
| `GET /api/roots/{root_id}/assets/{asset_path:path}` | Serve `.mediahive` assets via logical paths |
| `POST /api/roots/{root_id}/play` | Play file within root |
| `POST /api/roots/{root_id}/open-folder` | Open folder within root |
| `GET /api/roots/{root_id}/playback/resume-positions` | Per-root resume positions |
| `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` | Serve `.mediahive/{asset_type}` assets (`movies`, `series`, `people`) |
| `POST /api/play/{root_id}` | Play file within root |
| `POST /api/open-folder/{root_id}` | Open folder within root |
| `GET /api/meta/{root_id}/{meta_key}` | Per-root metadata (for example `playback-state`) |
| `POST /api/ui/pick-folder` | Native OS folder picker (returns path) |
> **Removed legacy endpoints**: `/api/change-folder`, `/api/index`, `/api/scan`, `/api/status`, `/api/playback/resume-positions`. No backwards compatibility is maintained.
@@ -93,4 +91,4 @@ All stored and transmitted paths use forward slashes exclusively:
- `App.vue` merges per-root `movieMap`/`seriesMap` into a single `mediaIndex`.
- `Header.vue` provides add/remove root UI via `PUT /api/roots`.
- Playback URLs are root-qualified (`/api/media/{root_id}/...`).
- Metadata cache assets use logical root paths (`/api/roots/{root_id}/assets/...`) rather than exposing `.mediahive` in URLs.
- Metadata cache assets use typed root paths (`/api/assets/{root_id}/{asset_type}/...`) rather than exposing `.mediahive` in URLs.
+18 -7
View File
@@ -113,6 +113,16 @@ function toRootAssetPath(path: string): string | null {
return logical.length > 0 ? logical : null
}
function splitAssetTypePath(assetPath: string): { assetType: string; relativePath: string } | null {
const parts = assetPath.split("/").filter((segment) => segment.length > 0)
if (parts.length < 2) return null
const [assetType, ...rest] = parts
if (!assetType || !["movies", "series", "people"].includes(assetType.toLowerCase())) {
return null
}
return { assetType: assetType.toLowerCase(), relativePath: rest.join("/") }
}
/**
* Fetch active roots and their statuses
*/
@@ -134,12 +144,10 @@ export async function fetchResumePositions(): Promise<Record<string, number>> {
const merged: Record<string, number> = {}
await Promise.all(
roots.map(async (root) => {
const response = await fetch(
`/api/roots/${encodeURIComponent(root.root_id)}/playback/resume-positions`,
)
const response = await fetch(`/api/meta/${encodeURIComponent(root.root_id)}/playback-state`)
if (!response.ok) return
const data = await response.json().catch(() => ({}))
const positions = data?.resume_positions
const positions = data?.data?.resume_positions
if (positions && typeof positions === "object") {
Object.assign(merged, positions)
}
@@ -195,7 +203,7 @@ export async function playMedia(
if (playerId) body.player_id = playerId
if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd
try {
const response = await fetch(`/api/roots/${encodeURIComponent(rootId)}/play`, {
const response = await fetch(`/api/play/${encodeURIComponent(rootId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -216,7 +224,7 @@ export async function playMedia(
export async function openFolder(rootId: string, folderPath: string): Promise<void> {
const normalizedPath = normalizeMediaPath(folderPath)
try {
const response = await fetch(`/api/roots/${encodeURIComponent(rootId)}/open-folder`, {
const response = await fetch(`/api/open-folder/${encodeURIComponent(rootId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder_path: normalizedPath }),
@@ -279,7 +287,10 @@ export function getCoverUrl(coverPath: string | null, rootId?: string | null): s
const rid = rootId || "unknown"
const assetPath = toRootAssetPath(coverPath)
if (assetPath) {
return `/api/roots/${encodeURIComponent(rid)}/assets/${encodePathSegments(assetPath)}`
const split = splitAssetTypePath(assetPath)
if (split) {
return `/api/assets/${encodeURIComponent(rid)}/${encodeURIComponent(split.assetType)}/${encodePathSegments(split.relativePath)}`
}
}
const mediaPath = normalizeCoverPath(coverPath)
@@ -464,7 +464,7 @@ export function useMediaWebSocket() {
}
const proto = location.protocol === "https:" ? "wss:" : "ws:"
const url = `${proto}//${location.host}/api/roots/${encodeURIComponent(rootId)}/ws`
const url = `${proto}//${location.host}/api/ws/${encodeURIComponent(rootId)}`
const state: RootState = {
rootId,
+1 -3
View File
@@ -34,9 +34,7 @@ Examples:
Exclude paths by creating .mediahive/scanignore (gitignore syntax).
The server exposes per-root endpoints:
WS /api/roots/{root_id}/ws Live index updates & task progress
POST /api/roots/{root_id}/scan Trigger a new scan
GET /api/roots/{root_id}/status Current root status
WS /api/ws/{root_id} Live index updates & task progress
""",
)
parser.add_argument(
+2 -2
View File
@@ -53,7 +53,7 @@ __all__ = [
class PlayMediaRequest(msgspec.Struct):
"""POST /api/roots/{root_id}/play body."""
"""POST /api/play/{root_id} body."""
file_path: str = ""
player_id: str | None = None
@@ -61,7 +61,7 @@ class PlayMediaRequest(msgspec.Struct):
class OpenFolderRequest(msgspec.Struct):
"""POST /api/roots/{root_id}/open-folder body."""
"""POST /api/open-folder/{root_id} body."""
folder_path: str = ""
+46 -53
View File
@@ -58,6 +58,7 @@ 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
@@ -75,22 +76,31 @@ def _get_context(root_id: str):
return ctx
def _load_resume_positions(root_path: Path) -> dict[str, int]:
playback_state_path = root_path / ".mediahive" / "playback-state.json"
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:
raw = json.loads(playback_state_path.read_text(encoding="utf-8"))
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:
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
raise HTTPException(
status_code=500, detail=f"Failed to load metadata: {meta_key}"
)
def _open_with_default_app(path: Path) -> None:
@@ -445,37 +455,10 @@ async def put_roots(request: Request):
}
@app.get("/api/roots/{root_id}/status")
async def get_root_status(root_id: str):
"""Return status for a single root."""
ctx = _get_context(root_id)
scanning = ctx.scanner is not None and ctx.scanner.is_scanning()
return {
"root_id": ctx.root_id,
"path": ctx.root_path.as_posix(),
"status": ctx.status,
"error": ctx.error,
"scanning": scanning,
"movies": len(ctx.store.movies),
"series": len(ctx.store.series),
"showreel_queue": ctx.scanner.showreel_queue_size() if ctx.scanner else 0,
}
@app.post("/api/roots/{root_id}/scan")
async def trigger_root_scan(root_id: str):
"""Trigger a scan for a single root."""
ctx = _get_context(root_id)
if ctx.scanner is None:
raise HTTPException(status_code=503, detail="Scanner not active")
started = ctx.scanner.trigger_scan()
return {"status": "started" if started else "already_running"}
# --- Per-root WebSocket ---
@app.websocket("/api/roots/{root_id}/ws")
@app.websocket("/api/ws/{root_id}")
async def ws_endpoint(ws: WebSocket, root_id: str) -> None:
"""Live index updates and task progress for a single root."""
ctx = supervisor.get(root_id)
@@ -503,7 +486,7 @@ async def list_players():
return {"players": [msgspec.structs.asdict(p) for p in players]}
@app.post("/api/roots/{root_id}/play")
@app.post("/api/play/{root_id}")
async def play_media(root_id: str, request: Request):
"""Open a media file with the selected player."""
ctx = _get_context(root_id)
@@ -537,7 +520,7 @@ async def play_media(root_id: str, request: Request):
raise HTTPException(status_code=500, detail=f"Failed to play media: {e}")
@app.post("/api/roots/{root_id}/open-folder")
@app.post("/api/open-folder/{root_id}")
async def open_folder(root_id: str, request: Request):
"""Open a folder in the system file explorer."""
ctx = _get_context(root_id)
@@ -572,11 +555,11 @@ async def open_folder(root_id: str, request: Request):
raise HTTPException(status_code=500, detail=f"Failed to open folder: {e}")
@app.get("/api/roots/{root_id}/playback/resume-positions")
async def root_playback_resume_positions(root_id: str):
"""Return saved per-file resume positions under a specific root."""
@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 {"resume_positions": _load_resume_positions(ctx.root_path)}
return {"key": meta_key, "data": _load_root_metadata(ctx.root_path, meta_key)}
# --- MPC-BE / Player status ---
@@ -704,13 +687,23 @@ async def serve_media_file(root_id: str, file_path: str, request: Request):
return _serve_file_response(full_path, file_path, request)
@app.get("/api/roots/{root_id}/assets/{asset_path:path}")
async def serve_root_asset_file(root_id: str, asset_path: str, request: Request):
"""Serve files from a root's .mediahive cache using logical asset paths."""
@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"
full_path = _resolve_root_scoped_path(base, asset_path)
return _serve_file_response(full_path, asset_path, request)
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)