Refactor API to flat root routes and WS/meta structure
This commit is contained in:
+8
-9
@@ -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/config` | Returns the current root configuration. |
|
||||||
| `GET` | `/api/roots` | List all active roots with status. |
|
| `GET` | `/api/roots` | List all active roots with status. |
|
||||||
| `PUT` | `/api/roots` | Atomically replace the full root set. |
|
| `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/play/{root_id}` | Opens a media file with the system player. |
|
||||||
| `POST` | `/api/roots/{root_id}/scan` | Triggers a new scan for one root. |
|
| `POST` | `/api/open-folder/{root_id}` | Opens a folder in the system file explorer. |
|
||||||
| `POST` | `/api/roots/{root_id}/play` | Opens a media file with the system player. |
|
| `GET` | `/api/meta/{root_id}/{meta_key}` | Returns allowed metadata from `<root>/.mediahive`. |
|
||||||
| `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. |
|
|
||||||
| `GET` | `/api/player/status` | Returns whether remote player control is currently available. |
|
| `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/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/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. |
|
| `GET` | `/api/assets/{root_id}/{asset_type}/{asset_path:path}` | Serves typed assets from `<root>/.mediahive`. |
|
||||||
| `WS` | `/api/roots/{root_id}/ws` | Streams live index updates and task progress for one root. |
|
| `WS` | `/api/ws/{root_id}` | Streams live index updates and task progress for one root. |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `PUT /api/roots` accepts `{ "roots": { "name": "/absolute/path", ... } }`, validates paths, and atomically swaps the active set.
|
- `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/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/player/status` returns `{ "remote": true|false }`.
|
||||||
- `GET /api/mpcbe/status` returns `false` on non-Windows platforms.
|
- `GET /api/mpcbe/status` returns `false` on non-Windows platforms.
|
||||||
|
|||||||
@@ -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) |
|
| `GET /api/roots` | List all roots (name, path, root_id, status) |
|
||||||
| `PUT /api/roots` | Atomically replace full root map `{name: path}` |
|
| `PUT /api/roots` | Atomically replace full root map `{name: path}` |
|
||||||
| `GET /api/roots/{root_id}/status` | Per-root scanning/loading/error state |
|
| `WS /api/ws/{root_id}` | Per-root WebSocket (init/upsert/remove/task + status/task events) |
|
||||||
| `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/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 |
|
| `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` | Serve `.mediahive/{asset_type}` assets (`movies`, `series`, `people`) |
|
||||||
| `POST /api/roots/{root_id}/play` | Play file within root |
|
| `POST /api/play/{root_id}` | Play file within root |
|
||||||
| `POST /api/roots/{root_id}/open-folder` | Open folder within root |
|
| `POST /api/open-folder/{root_id}` | Open folder within root |
|
||||||
| `GET /api/roots/{root_id}/playback/resume-positions` | Per-root resume positions |
|
| `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) |
|
| `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.
|
> **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`.
|
- `App.vue` merges per-root `movieMap`/`seriesMap` into a single `mediaIndex`.
|
||||||
- `Header.vue` provides add/remove root UI via `PUT /api/roots`.
|
- `Header.vue` provides add/remove root UI via `PUT /api/roots`.
|
||||||
- Playback 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.
|
- Metadata cache assets use typed root paths (`/api/assets/{root_id}/{asset_type}/...`) rather than exposing `.mediahive` in URLs.
|
||||||
|
|||||||
+18
-7
@@ -113,6 +113,16 @@ function toRootAssetPath(path: string): string | null {
|
|||||||
return logical.length > 0 ? logical : 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
|
* Fetch active roots and their statuses
|
||||||
*/
|
*/
|
||||||
@@ -134,12 +144,10 @@ export async function fetchResumePositions(): Promise<Record<string, number>> {
|
|||||||
const merged: Record<string, number> = {}
|
const merged: Record<string, number> = {}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
roots.map(async (root) => {
|
roots.map(async (root) => {
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/meta/${encodeURIComponent(root.root_id)}/playback-state`)
|
||||||
`/api/roots/${encodeURIComponent(root.root_id)}/playback/resume-positions`,
|
|
||||||
)
|
|
||||||
if (!response.ok) return
|
if (!response.ok) return
|
||||||
const data = await response.json().catch(() => ({}))
|
const data = await response.json().catch(() => ({}))
|
||||||
const positions = data?.resume_positions
|
const positions = data?.data?.resume_positions
|
||||||
if (positions && typeof positions === "object") {
|
if (positions && typeof positions === "object") {
|
||||||
Object.assign(merged, positions)
|
Object.assign(merged, positions)
|
||||||
}
|
}
|
||||||
@@ -195,7 +203,7 @@ export async function playMedia(
|
|||||||
if (playerId) body.player_id = playerId
|
if (playerId) body.player_id = playerId
|
||||||
if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd
|
if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/roots/${encodeURIComponent(rootId)}/play`, {
|
const response = await fetch(`/api/play/${encodeURIComponent(rootId)}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -216,7 +224,7 @@ export async function playMedia(
|
|||||||
export async function openFolder(rootId: string, folderPath: string): Promise<void> {
|
export async function openFolder(rootId: string, folderPath: string): Promise<void> {
|
||||||
const normalizedPath = normalizeMediaPath(folderPath)
|
const normalizedPath = normalizeMediaPath(folderPath)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/roots/${encodeURIComponent(rootId)}/open-folder`, {
|
const response = await fetch(`/api/open-folder/${encodeURIComponent(rootId)}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ folder_path: normalizedPath }),
|
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 rid = rootId || "unknown"
|
||||||
const assetPath = toRootAssetPath(coverPath)
|
const assetPath = toRootAssetPath(coverPath)
|
||||||
if (assetPath) {
|
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)
|
const mediaPath = normalizeCoverPath(coverPath)
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ export function useMediaWebSocket() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:"
|
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 = {
|
const state: RootState = {
|
||||||
rootId,
|
rootId,
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ Examples:
|
|||||||
Exclude paths by creating .mediahive/scanignore (gitignore syntax).
|
Exclude paths by creating .mediahive/scanignore (gitignore syntax).
|
||||||
|
|
||||||
The server exposes per-root endpoints:
|
The server exposes per-root endpoints:
|
||||||
WS /api/roots/{root_id}/ws Live index updates & task progress
|
WS /api/ws/{root_id} Live index updates & task progress
|
||||||
POST /api/roots/{root_id}/scan Trigger a new scan
|
|
||||||
GET /api/roots/{root_id}/status Current root status
|
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ __all__ = [
|
|||||||
|
|
||||||
|
|
||||||
class PlayMediaRequest(msgspec.Struct):
|
class PlayMediaRequest(msgspec.Struct):
|
||||||
"""POST /api/roots/{root_id}/play body."""
|
"""POST /api/play/{root_id} body."""
|
||||||
|
|
||||||
file_path: str = ""
|
file_path: str = ""
|
||||||
player_id: str | None = None
|
player_id: str | None = None
|
||||||
@@ -61,7 +61,7 @@ class PlayMediaRequest(msgspec.Struct):
|
|||||||
|
|
||||||
|
|
||||||
class OpenFolderRequest(msgspec.Struct):
|
class OpenFolderRequest(msgspec.Struct):
|
||||||
"""POST /api/roots/{root_id}/open-folder body."""
|
"""POST /api/open-folder/{root_id} body."""
|
||||||
|
|
||||||
folder_path: str = ""
|
folder_path: str = ""
|
||||||
|
|
||||||
|
|||||||
+46
-53
@@ -58,6 +58,7 @@ supervisor = Supervisor()
|
|||||||
_attach_scanners_lock = asyncio.Lock()
|
_attach_scanners_lock = asyncio.Lock()
|
||||||
|
|
||||||
_RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
|
_RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
|
||||||
|
_ROOT_ASSET_TYPES = {"movies", "series", "people"}
|
||||||
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
from ctypes import wintypes
|
from ctypes import wintypes
|
||||||
@@ -75,22 +76,31 @@ def _get_context(root_id: str):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
def _load_resume_positions(root_path: Path) -> dict[str, int]:
|
def _load_root_metadata(root_path: Path, meta_key: str):
|
||||||
playback_state_path = root_path / ".mediahive" / "playback-state.json"
|
"""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:
|
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:
|
except OSError, TypeError, json.JSONDecodeError:
|
||||||
return {}
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to load metadata: {meta_key}"
|
||||||
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:
|
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 ---
|
# --- 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:
|
async def ws_endpoint(ws: WebSocket, root_id: str) -> None:
|
||||||
"""Live index updates and task progress for a single root."""
|
"""Live index updates and task progress for a single root."""
|
||||||
ctx = supervisor.get(root_id)
|
ctx = supervisor.get(root_id)
|
||||||
@@ -503,7 +486,7 @@ async def list_players():
|
|||||||
return {"players": [msgspec.structs.asdict(p) for p in 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):
|
async def play_media(root_id: str, request: Request):
|
||||||
"""Open a media file with the selected player."""
|
"""Open a media file with the selected player."""
|
||||||
ctx = _get_context(root_id)
|
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}")
|
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):
|
async def open_folder(root_id: str, request: Request):
|
||||||
"""Open a folder in the system file explorer."""
|
"""Open a folder in the system file explorer."""
|
||||||
ctx = _get_context(root_id)
|
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}")
|
raise HTTPException(status_code=500, detail=f"Failed to open folder: {e}")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/roots/{root_id}/playback/resume-positions")
|
@app.get("/api/meta/{root_id}/{meta_key}")
|
||||||
async def root_playback_resume_positions(root_id: str):
|
async def root_metadata(root_id: str, meta_key: str):
|
||||||
"""Return saved per-file resume positions under a specific root."""
|
"""Return a root metadata value from .mediahive for allowed keys."""
|
||||||
ctx = _get_context(root_id)
|
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 ---
|
# --- 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)
|
return _serve_file_response(full_path, file_path, request)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/roots/{root_id}/assets/{asset_path:path}")
|
@app.get("/api/assets/{root_id}/{asset_type}/{asset_path:path}")
|
||||||
async def serve_root_asset_file(root_id: str, asset_path: str, request: Request):
|
async def serve_root_asset_file(
|
||||||
"""Serve files from a root's .mediahive cache using logical asset paths."""
|
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)
|
ctx = _get_context(root_id)
|
||||||
base = ctx.root_path / ".mediahive"
|
base = ctx.root_path / ".mediahive"
|
||||||
full_path = _resolve_root_scoped_path(base, asset_path)
|
logical_path = f"{asset_type_key}/{asset_path}"
|
||||||
return _serve_file_response(full_path, asset_path, request)
|
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)
|
# Serve the Vue frontend (needs to be last if SPA catch-all is used)
|
||||||
|
|||||||
Reference in New Issue
Block a user