diff --git a/docs/API.md b/docs/API.md index cf114f4..f20817f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -19,6 +19,7 @@ All media paths are scoped to a **root**, identified by a stable `root_id`. | `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. | ## Notes @@ -26,5 +27,6 @@ All media paths are scoped to a **root**, identified by a stable `root_id`. - `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. - `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/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 06a2c17..c45e5d6 100644 --- a/docs/multi-index-plan.md +++ b/docs/multi-index-plan.md @@ -51,6 +51,7 @@ Each active root gets an isolated `RootContext` managed by the `Supervisor`: | `POST /api/roots/{root_id}/scan` | Trigger scan for one root | | `WS /api/roots/{root_id}/ws` | Per-root WebSocket (init/upsert/remove/task) | | `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 | @@ -91,4 +92,5 @@ All stored and transmitted paths use forward slashes exclusively: - `useMediaWebSocket.ts` manages one WebSocket per active root. - `App.vue` merges per-root `movieMap`/`seriesMap` into a single `mediaIndex`. - `Header.vue` provides add/remove root UI via `PUT /api/roots`. -- All media URLs are root-qualified (`/api/media/{root_id}/...`). +- 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. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4cbc21d..bb5875d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -89,6 +89,30 @@ export function getVideoSourceAttributes(path: string | null | undefined): Video return { type: "video/mp4", codecs: "hvc1" } } +function encodePathSegments(path: string): string { + return path + .split("/") + .filter((segment) => segment.length > 0) + .map((segment) => encodeURIComponent(segment)) + .join("/") +} + +function normalizeCoverPath(path: string): string { + return path + .replace(/\\/g, "/") + .replace(/^[A-Za-z]:\//, "") + .replace(/^\/+/, "") +} + +function toRootAssetPath(path: string): string | null { + const normalized = normalizeCoverPath(path) + const marker = ".mediahive/" + const idx = normalized.toLowerCase().indexOf(marker) + if (idx < 0) return null + const logical = normalized.slice(idx + marker.length) + return logical.length > 0 ? logical : null +} + /** * Fetch active roots and their statuses */ @@ -252,29 +276,14 @@ export function getCoverUrl(coverPath: string | null, rootId?: string | null): s return "" } - // Convert relative path to URL path for FastAPI server - // .mediahive/covers/Movies/... -> /api/media/{root_id}/.mediahive/covers/Movies/... - let urlPath = coverPath - - // Remove drive letter (Z:) and convert backslashes to forward slashes - if (urlPath.match(/^[A-Za-z]:/)) { - urlPath = urlPath.substring(2) - } - urlPath = urlPath.replace(/\\/g, "/") - - // Ensure path starts with / - if (!urlPath.startsWith("/")) { - urlPath = "/" + urlPath - } - - // Encode URI components but preserve slashes - const encodedPath = urlPath - .split("/") - .map((segment) => encodeURIComponent(segment)) - .join("/") - const rid = rootId || "unknown" - return `/api/media/${encodeURIComponent(rid)}${encodedPath}` + const assetPath = toRootAssetPath(coverPath) + if (assetPath) { + return `/api/roots/${encodeURIComponent(rid)}/assets/${encodePathSegments(assetPath)}` + } + + const mediaPath = normalizeCoverPath(coverPath) + return `/api/media/${encodeURIComponent(rid)}/${encodePathSegments(mediaPath)}` } /** diff --git a/mediahive/server.py b/mediahive/server.py index 8c9f0c6..cfe47c9 100644 --- a/mediahive/server.py +++ b/mediahive/server.py @@ -616,18 +616,18 @@ def _mpcbe_request(path: str, timeout: float = 0.75, port: int | None = None) -> # --- Media file serving --- -@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 = ctx.root_path / file_path.lstrip("/") - - # Security: ensure path doesn't escape base +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: - full_path.resolve().relative_to(ctx.root_path.resolve()) + 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}") @@ -696,5 +696,22 @@ async def serve_media_file(root_id: str, file_path: str, request: Request): ) +@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/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.""" + 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) + + # Serve the Vue frontend (needs to be last if SPA catch-all is used) frontend.route(app, "/")