From 0ef689618158865855385f35b53f44b2cb2072f7 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 30 May 2026 02:09:24 +0000 Subject: [PATCH] Refactor API to flat root routes and WS/meta structure --- docs/API.md | 17 ++-- docs/multi-index-plan.md | 14 ++- frontend/src/api.ts | 25 +++-- frontend/src/composables/useMediaWebSocket.ts | 2 +- mediahive/hivescan/__main__.py | 4 +- mediahive/models/protocol.py | 4 +- mediahive/server.py | 99 +++++++++---------- 7 files changed, 82 insertions(+), 83 deletions(-) diff --git a/docs/API.md b/docs/API.md index f20817f..2557285 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 `/.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 `/.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 `/.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 `/.mediahive`; metadata image URLs should use this endpoint. +- `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` is constrained to `/.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. diff --git a/docs/multi-index-plan.md b/docs/multi-index-plan.md index c45e5d6..a7b99ff 100644 --- a/docs/multi-index-plan.md +++ b/docs/multi-index-plan.md @@ -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. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bb5875d..3168107 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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> { const merged: Record = {} 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 { 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) diff --git a/frontend/src/composables/useMediaWebSocket.ts b/frontend/src/composables/useMediaWebSocket.ts index 5b86476..b1acacc 100644 --- a/frontend/src/composables/useMediaWebSocket.ts +++ b/frontend/src/composables/useMediaWebSocket.ts @@ -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, diff --git a/mediahive/hivescan/__main__.py b/mediahive/hivescan/__main__.py index beba38f..1d92628 100644 --- a/mediahive/hivescan/__main__.py +++ b/mediahive/hivescan/__main__.py @@ -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( diff --git a/mediahive/models/protocol.py b/mediahive/models/protocol.py index c69e630..63341e4 100644 --- a/mediahive/models/protocol.py +++ b/mediahive/models/protocol.py @@ -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 = "" diff --git a/mediahive/server.py b/mediahive/server.py index cfe47c9..ccf0795 100644 --- a/mediahive/server.py +++ b/mediahive/server.py @@ -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)