Library Management β
This page documents two layers:
- The admin UI Libraries page (the operator workflow β list, add, edit, delete, scan, history).
- The HTTP API contract the page consumes β the filesystem-browse picker (step 0.6), the async scan endpoints (step 1.1b), and the allowed-roots jail.
The Libraries page is the first feature page built on top of the Admin SPA scaffold (step 0.4).
Managing libraries in the admin UI β
The admin console exposes a Libraries page at /admin/libraries for managing every media library on this server. The page is admin-gated (same gate as the rest of /admin/* β non-admin requests are redirected to /login).
Reaching the page β
Open /admin in a browser, sign in as an admin user, then click Libraries in the left-hand sidebar (under Dashboard). The page renders a single DataTable of every library currently registered with the server.
The library list β
For each library the table shows:
| Column | Source |
|---|---|
| Name | library.name |
| Type | library.type (one of movie, series, music, photo, video) |
| Paths | A count (e.g. 2 paths) β the full list appears in the edit form |
| Status | A status badge (Idle / Queued / Running⦠/ Completed / Failed) sourced from the latest scan job |
| Actions | Edit, Scan, Rescan, History, Delete |
When the library list is empty the page renders an empty-state message instead of an empty table. A load error (network failure or a non-2xx from GET /api/v1/libraries) raises a toast carrying the server error string β every server string is rendered as React text only (no dangerouslySetInnerHTML, so untrusted names can never inject HTML).
Adding a library β
Click Add library to open a modal with a form:
| Field | Notes |
|---|---|
| Name | Free-text label for the library. |
| Type | A select of the five DB-valid types: movie, series, music, photo, video. |
| Paths | One or more directories chosen via the PathPicker (see below). At least one path is required. |
Submitting POSTs { name, type, paths, options? } to /api/v1/libraries. On 201 the modal closes, a success toast appears, and the list refreshes. A 400 (validation error) surfaces the server's error message as a toast.
book is deliberately not offered
The libraries.type ENUM in migration 001_initial_schema.sql is exactly movie|series|music|photo|video. The PHP controller LibraryController::create()also lists book in its $validTypes, but a book insert would 500 at the DB ENUM β so the UI excludes it. The controller/DB mismatch is a known pre-existing backend bug tracked as a carry-over for a later step.
The PathPicker β
The path field is a small directory picker that drives the GET /api/v1/admin/fs/browse endpoint:
- The initial view lists the configured roots (see Allowed Roots).
- Click a directory name to drill into it; click Up to walk back to its parent (disabled at a root).
- Click Select this folder to add the current directory to the selected list.
- Selected paths show a Remove link to drop them again; duplicates are deduplicated.
Every directory name returned by the server is rendered as React text β an HTML-looking directory name is rendered as literal text, never parsed as markup.
Editing a library β
Click Edit on a row to open the same form, pre-filled with the current values. The form PUTs { name, paths, options? } to /api/v1/libraries/{id}.
Type is read-only on edit
The PHP LibraryController::update() silently ignores type (the column is not updatable), so the form displays the existing type read-only and the SPA never sends type in a PUT payload. To change a library's type, delete it and re-add it.
Deleting a library β
Click Delete on a row to open a confirm modal. Confirming DELETEs /api/v1/libraries/{id}; success refreshes the list and shows a toast. A 404 (library already gone) surfaces a toast too.
Triggering a scan or rescan β
Each row has Scan and Rescan buttons. Both call the async scan endpoints and return immediately with a 202 + a job_id. The page shows a "queued" toast with the returned message and starts polling status for that library.
- Scan runs an incremental scan (new + changed files).
- Rescan runs a full purge + rescan.
Neither button blocks β the work happens in the background Library Scan Worker. You can navigate away; the next time you visit the page the status badge picks up the current state of the latest job.
Reading the live status β
Once a scan is queued β and on initial load for a library that already has a job β the page polls GET /api/v1/libraries/{id}/scan-status every 2 seconds for that library. Polling stops as soon as the job reaches a terminal state (completed or failed), or when the endpoint returns null (no job has ever run). The status badge then carries the final value.
If a scan fails, the badge shows Failed and the page surfaces the server error string as React text.
Per-file progress is not reported in this release
The status badge tracks the lifecycle only β queued β running β completed/failed. The 1.1b worker does not emit per-file counts: items_found, items_added, items_updated, items_removed stay at 0 and current_path stays null. The page deliberately does not render a fabricated per-file progress bar. See the Library Scan Worker β coarse progress is intentional note for why.
Reviewing scan history β
Click History on a row to open a modal that loads GET /api/v1/libraries/{id}/scan-history?limit=20 and lists recent jobs (newest first) in a DataTable:
| Column | Source |
|---|---|
| Type | scan / rescan |
| Status | queued / running / completed / failed |
| Queued at | queued_at |
| Completed at | completed_at (or empty for jobs still in flight) |
| Error | error (only for failed jobs) |
The server clamps limit to [1, 100] and defaults to 20; the UI uses the default.
Browse Filesystem β
The admin UI's PathPicker is the canonical consumer of this endpoint. The contract below documents the wire format for that picker and for any future tooling.
GET /api/v1/admin/fs/browse?path=<absolute-path>Lists the immediate subdirectories of path (files are excluded), sorted by name. The result is restricted to the configured allowed roots β any path that resolves outside them is rejected.
Query Parameters β
| Parameter | Required | Description |
|---|---|---|
path | No | Absolute path whose subdirectories to list. When empty or absent, the configured allowed roots are returned as the entry list (the picker's starting point). |
Authentication β
The endpoint sits in the /api/v1/admin route group and is gated by the admin middleware. Send a valid admin JWT as a Bearer token:
Authorization: Bearer <admin-access-token>- An unauthenticated request returns
401. - A non-admin request returns
403.
Both error responses are JSON.
Responses β
Starting point (no path) β
With an empty or absent path, the configured roots are returned as the entry list so the picker has somewhere to begin. path and parent are null:
{
"success": true,
"data": {
"path": null,
"parent": null,
"entries": [
{ "name": "home", "path": "/home" },
{ "name": "mnt", "path": "/mnt" },
{ "name": "media", "path": "/media" },
{ "name": "data", "path": "/data" }
]
}
}Directory listing β
For a valid directory under an allowed root, entries holds its immediate subdirectories (sorted by name), path is the canonical (resolved) directory, and parent is the parent directory only when the parent is itself within the jail β otherwise null (so the picker stops at a root):
{
"success": true,
"data": {
"path": "/media/movies",
"parent": "/media",
"entries": [
{ "name": "Action", "path": "/media/movies/Action" },
{ "name": "Comedy", "path": "/media/movies/Comedy" }
]
}
}Error responses β
| Status | When | Body |
|---|---|---|
400 | The path resolves but is not a directory (e.g. a file). | { "success": false, "error": "Not a directory" } |
403 | The path resolves outside the allowed roots β including ../ escapes and symlinks that point out of the jail. | { "success": false, "error": "Path is outside the allowed roots" } |
404 | The path does not exist / cannot be resolved by realpath(). | { "success": false, "error": "Path not found" } |
The checks run in the order 404 β 400 β 403, so a non-existent or non-directory path reports the more specific 404/400 rather than 403.
Scanning a Library β
Scanning indexes a library's filesystem for media and updates the catalog. As of Phase 1.1b the scan runs asynchronously β off the HTTP request. The scan and rescan endpoints no longer scan inline; they enqueue a job and return 202 immediately, and a background Library Scan Worker drains the queue. Use the scan-status endpoint to poll a job's progress.
The admin UI wraps all four endpoints below: per-row Scan / Rescan buttons hit the enqueue endpoints, the page polls scan-status every 2 seconds (stopping on terminal status), and a History modal shows the most recent jobs from scan-history.
All four endpoints below are admin-gated (the scan-status job row exposes a server filesystem path in current_path), require a valid admin Bearer token (401 unauthenticated, 403 non-admin), and return 404 when the library does not exist.
Enqueue a scan β
POST /api/v1/libraries/{id}/scanQueues an incremental scan. Returns 202 Accepted with the new job id:
{
"job_id": "550e8400-e29b-41d4-a716-446655440099",
"status": "queued",
"message": "Library scan queued"
}Enqueue a rescan β
POST /api/v1/libraries/{id}/rescanQueues a full rescan (purge + rescan). Identical contract to scan, with a rescan-typed job and the message "Library rescan queued":
{
"job_id": "550e8400-e29b-41d4-a716-446655440100",
"status": "queued",
"message": "Library rescan queued"
}CLI is still synchronous
The php bin/phlix library:scan {libraryId} [--rescan] console command is unchanged β it scans synchronously and blocks until done. Only the HTTP endpoints became asynchronous.
Scan status β
GET /api/v1/libraries/{id}/scan-statusReturns the latest scan job for the library, or null when the library has never been scanned (still a valid 200, not a 404):
{
"scan_status": {
"id": "550e8400-e29b-41d4-a716-446655440099",
"library_id": "550e8400-e29b-41d4-a716-446655440001",
"type": "scan",
"status": "running",
"items_found": 0,
"items_added": 0,
"items_updated": 0,
"items_removed": 0,
"current_path": null,
"error": null,
"queued_at": "2026-05-27 12:00:00",
"started_at": "2026-05-27 12:00:05",
"completed_at": null
}
}A UI polls this endpoint after enqueueing to follow the job through its lifecycle: queued β running β completed (or failed, where error carries the exception message).
Progress is coarse, not per-item
In this release status is the only live signal. LibraryManager reports no per-file counts, so items_found / items_added / items_updated / items_removed stay 0 and current_path stays null. Treat scan-status as a lifecycle indicator (queued / running / completed / failed), not a live per-file progress bar. Real per-item counters may be wired through in a later step. See the Library Scan Worker developer page.
Scan history β
GET /api/v1/libraries/{id}/scan-history?limit=NReturns recent scan jobs for the library, newest first. limit defaults to 20 and is clamped to [1, 100]:
{
"history": [
{ "id": "β¦", "type": "scan", "status": "completed", "queued_at": "β¦", "completed_at": "β¦" }
]
}Each entry has the same shape as the scan_status job row above.
Allowed Roots β
Directory listing is jailed to the roots declared in config/filesystem.php:
return [
'browse_roots' => ['/home', '/mnt', '/media', '/data'],
];| Root | Purpose |
|---|---|
/home | User home directories. |
/mnt | Mounted volumes. |
/media | Removable / external media mounts. |
/data | Application / library data volume. |
This list is the security boundary for the endpoint β keep it conservative. There is intentionally no environment-variable override, so the boundary stays explicit and auditable in code. Each root is canonicalised with realpath() at startup; a configured root that does not resolve on the host is silently dropped (it can never be browsed).
The Traversal Jail β
Every candidate path is canonicalised with realpath() before any check. Because realpath() collapses .. segments and resolves symlinks to their real targets, a single prefix test against each root is enough to keep the listing inside the jail:
$real === $root || str_starts_with($real . '/', $root . '/')The trailing-slash form is deliberate (a plain prefix check, neverstr_contains): it ensures a sibling such as /home-backup cannot match the /home root, while /home/alice does. The consequences:
- A
../path escaping a root canonicalises to its real location and fails the prefix test β403. - A symlink pointing outside the jail resolves to its real target via
realpath()and fails the prefix test β403. - A path under no configured root β
403.
This mirrors the canonical path-jail pattern used elsewhere in the server (e.g. AudiobookController::validateMediaPath()), so the browse endpoint cannot be used to read directory structure outside the allowed roots.
See Also β
- Library Scan Worker β how the async scan queue and worker work
- Server Settings β server-wide settings store and admin API
- Dashboard β visual admin dashboard overview
- Stats β usage and activity statistics