Phlix Media Server API Reference β
Since: 0.18.0
Overview β
Phlix exposes a REST API at /api/v1/ returning JSON. Authentication uses JWT Bearer tokens (except on /api/v1/auth/* endpoints, which are unauthenticated). The API is documented by this hand-maintained reference page; there is no auto-generated OpenAPI spec or interactive API explorer at this time.
Future work: A machine-readable OpenAPI specification may be provided in a future release. It is not available today.
Auth Endpoints β
POST /api/v1/auth/register β
Register a new user account.
Request body:
{
"email": "user@example.com",
"username": "username",
"password": "strongpassword123"
}Response 201:
{
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"username": "username"
},
"access_token": "eyJ...",
"refresh_token": "eyJ..."
}Response 422: Validation error (missing fields, weak password, email already in use)
POST /api/v1/auth/login β
Authenticate and receive JWT tokens.
Request body:
{
"username": "user@example.com",
"password": "strongpassword123"
}Response 200:
{
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"username": "username"
},
"access_token": "eyJ...",
"refresh_token": "eyJ..."
}Response 401: Invalid credentials
POST /api/v1/auth/refresh β
Refresh an expired access token using a valid refresh token.
Request body:
{
"refresh_token": "eyJ..."
}Response 200:
{
"access_token": "eyJ...",
"refresh_token": "eyJ..."
}Response 401: Refresh token expired or invalid
Library Endpoints β
GET /api/v1/libraries β
List all configured libraries, ordered by display_order then name. The SPA Browse home renders one rail per library in this order (and the media server's nav renders one Browse link per library β see below).
Auth: Required (Bearer token)
Response 200:
{
"libraries": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"name": "Movies",
"type": "movie",
"path": "/mnt/media/movies",
"item_count": 342
}
]
}POST /api/v1/libraries β
Create a new library.
Auth: Required (Bearer token)
Request body:
{
"name": "TV Shows",
"type": "series",
"path": "/mnt/media/tv"
}Response 201:
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"name": "TV Shows",
"type": "series",
"path": "/mnt/media/tv"
}Response 400: Missing required fields or invalid type
POST /api/v1/libraries/{id}/scan β
Enqueue an incremental scan of a library. As of Phase 1.1b the scan runs asynchronously β this endpoint queues a job and returns immediately; a background worker performs the scan. Poll GET .../scan-status for progress.
Auth: Admin (Bearer token) β 401 unauthenticated, 403 non-admin
Response 202:
{
"job_id": "550e8400-e29b-41d4-a716-446655440099",
"status": "queued",
"message": "Library scan queued"
}Response 404: Library not found
POST /api/v1/libraries/{id}/rescan β
Enqueue a full rescan (purge + rescan). Same contract as scan with a rescan-typed job and the message "Library rescan queued".
Auth: Admin (Bearer token)
Response 202:
{
"job_id": "550e8400-e29b-41d4-a716-446655440100",
"status": "queued",
"message": "Library rescan queued"
}Response 404: Library not found
GET /api/v1/libraries/{id}/scan-status β
Return the latest scan job for the library, or null when it has never been scanned (still a 200). The endpoint is type-agnostic (scan, rescan, and metadata/match jobs all report onto the same row). For movie/series/video libraries the job streams live per-file progress: items_found is the total media-file count and items_updated the processed count (items_updated / items_found is the percentage), with current_path the file being processed. items_added/items_removed are not streamed (stay 0); the specialised music/photo/book/audiobook scanners stay coarse (status is the live signal).
Auth: Admin (Bearer token)
Response 200:
{
"scan_status": {
"id": "550e8400-e29b-41d4-a716-446655440099",
"library_id": "550e8400-e29b-41d4-a716-446655440001",
"type": "scan",
"status": "running",
"items_found": 1280,
"items_added": 0,
"items_updated": 432,
"items_removed": 0,
"current_path": "/media/movies/Action/Heat (1995)/Heat.mkv",
"error": null,
"queued_at": "2026-05-27 12:00:00",
"started_at": "2026-05-27 12:00:05",
"completed_at": null
}
}Response 404: Library not found
GET /api/v1/libraries/{id}/scan-history β
Return recent scan jobs for the library, newest first. limit defaults to 20 and is clamped to [1, 100]. Each entry has the same shape as the scan_status job row.
Query Parameters: limit (optional, default 20, clamped 1β100)
Auth: Admin (Bearer token)
Response 200:
{
"history": [
{
"id": "550e8400-e29b-41d4-a716-446655440099",
"type": "scan",
"status": "completed",
"queued_at": "2026-05-27 12:00:00",
"completed_at": "2026-05-27 12:03:11"
}
]
}Response 404: Library not found
Media Endpoints β
GET /api/v1/media β
List media items across all libraries, or scoped to a single library. This is the endpoint that backs the SPA Browse rails and the per-library Browse grid.
Auth: Required (Bearer token)
Query parameters (all optional):
| Parameter | Type | Notes |
|---|---|---|
libraryId | UUID | Scope results and total to a single library. Absent or blank = all libraries (the default β unchanged). |
parentId | UUID | Scope to the direct children of one item β the seasons/episodes of a series (or the episodes of a season). Drives the series detail drill-down. Mutually exclusive with topLevel. |
topLevel | 1/true | Return only top-level items (those with no parent: movies + series), excluding seasons and episodes. Browse rails and library grids set this so a series library shows shows, not a flat dump of every episode. Ignored when search is set (so search still spans the whole library). Mutually exclusive with parentId. |
search | string | Free-text title match. |
genres[] | string[] | Filter by one or more genres. |
yearFrom / yearTo | int | Release-year range. |
ratings[] | string[] | Filter by one or more content ratings. |
actors[] | string[] | Filter by one or more cast members. |
sort | string | Sort field (e.g. name, year, added). |
order | string | asc or desc. |
limit / offset | int | Pagination window (limit is capped at 100). |
Response 200:
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"name": "The Matrix",
"type": "movie",
"poster_url": null,
"genres": ["Action", "Sci-Fi"],
"year": 1999,
"rating": "R",
"runtime": 8160,
"overview": "A hacker learns the truth...",
"actors": ["Keanu Reeves"],
"director": "The Wachowskis",
"parent_id": null,
"season_number": null,
"episode_number": null,
"episode_title": null,
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-02T00:00:00+00:00"
}
],
"total": 342
}When libraryId is supplied, total reflects the count within that one library (used to drive per-library pagination on /app/library/:id).
Series hierarchy fields β
Every item carries the seriesβseasonβepisode hierarchy (all null for flat content such as movies):
| Field | Type | Notes |
|---|---|---|
type | string | movie Β· series Β· season Β· episode Β· audio Β· image. series/season/episode form the TV/anime tree. |
parent_id | UUID | null | Parent item (episode β season β series). null for top-level items (movies, series). |
season_number | int | null | Season this item belongs to (from metadata). Season 0 / a null number on a series episode = Specials. |
episode_number | int | null | Episode number within its season; orders episodes. |
episode_title | string | null | Per-episode title, distinct from name (which may be the series name). |
Drilling into a series. The SPA shows series libraries as a list of shows (topLevel=1); opening a series fetches its tree:
GET /api/v1/media?parentId=<seriesId>&limit=100Episodes are grouped client-side by season_number (Specials last) and ordered by episode_number. When a server models seasons as their own type: "season" rows, the client fetches each season's children (parentId=<seasonId>) and flattens them, so grouping is uniformly by season_number either way.
GET /api/v1/media/{id} β
Get a single media item by ID.
Auth: Required (Bearer token)
Parameters:
id(path) β Media item UUID
Response 200:
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"name": "S01E01 - Pilot",
"type": "episode",
"path": "/mnt/media/tv/show/s01e01.mkv",
"duration": 2520,
"metadata": {
"title": "Pilot",
"year": 2020,
"summary": "The pilot episode..."
},
"user_data": {
"favorite": false,
"rating": null,
"like_level": 0
}
}The user_data block is add-only and per-user (account-level, keyed on user_id β not per-profile). It is present only on this single-item detail response (and on the favorites list below); browse/list rows do not carry it.
favorite(boolean) β whether the current user has favorited the item.rating(int 1-10 | null) β the current user's personal rating (nullwhen unset).like_level(int 0-3) β the current user's multi-level "Love" value (0= not loved β¦3= most loved). Since: 0.57.0.
user_data is null when the request is unauthenticated; when authenticated with no stored row it defaults to { "favorite": false, "rating": null, "like_level": 0 }.
Response 404: Media item not found
User Item Data Endpoints β
Per-user favorites, personal ratings, and the multi-level Love value for any media item. All routes require authentication (Bearer token). Favorites/ratings/Love are account-level (keyed on user_id, like user settings) β not per-profile. Each write returns a { "message": "..." } envelope.
Hub relay caveat: when a server is browsed through the hub's relay proxy, these write endpoints degrade β the relay proxy allowlists
GET/HEADonly, soPOST .../favoritereturns403 proxy.scope_deniedandPUT .../like+DELETE .../favoriteare not routed. Favorites and Love writes only persist over a direct session to the server. Tracked as phlix-hub issue #122.
POST /api/v1/media/{id}/favorite β
Mark a media item as one of the current user's favorites.
Auth: Required Β· Body: none Β· Response 200: { "message": "Added to favorites" }
DELETE /api/v1/media/{id}/favorite β
Remove a media item from the current user's favorites.
Auth: Required Β· Body: none Β· Response 200: { "message": "Removed from favorites" }
PUT /api/v1/media/{id}/rating β
Set the current user's personal rating for the item.
Auth: Required
Body:
{ "rating": 8 }rating(int 1-10 | null) βnullclears the rating.
Response 200: { "message": "Rating saved" }Response 400: non-numeric or out-of-range rating Response 404: media item not found
DELETE /api/v1/media/{id}/rating β
Clear the current user's personal rating.
Auth: Required Β· Body: none Β· Response 200: { "message": "Rating saved" }
PUT /api/v1/media/{id}/like β
Set the current user's multi-level Love value for the item.
Since: 0.57.0
Auth: Required
Body:
{ "level": 2 }level(int 0-3, required) β0= not loved β¦3= most loved. Thelevelfield is required (there is no "clear" / null branch β set0to unset). The 0-3 range is enforced in PHP (no DBCHECKconstraint), andlike_levelis a separate axis fromfavorite(boolean) andrating(1-10).
Response 200: { "message": "Love level saved" }Response 400: missing/non-integer level, or a value outside 0-3Response 401: unauthenticated Response 404: media item not found
GET /api/v1/users/me/favorites β
List the current user's favorited items.
Auth: Required
Query parameters:
limit(optional) β clamped to1-100(default50).offset(optional) β floored at0(default0).
Response 200:
{
"items": [ /* shaped media items, each with a `user_data` block */ ],
"limit": 50,
"offset": 0
}Each item carries the same add-only user_data: { favorite, rating, like_level } block as the detail response (with favorite: true). The response has no total field (unlike the browse list).
Transcoding Endpoints β
Files the browser can't direct-play (non-web containers like MKV, or codecs like HEVC) are transcoded on demand to HLS and played via hls.js. The flow is: POST .../transcode β poll GET /api/v1/transcode/{jobId}/status until playlist_ready β play master_url (served by the HLS routes below).
POST /api/v1/media/{id}/transcode β
Start (or reuse) an on-demand HLS transcode job for a media item. Idempotent: a still-valid job for the same item + profile is reused instead of starting a second FFmpeg. The encode runs detached, so this returns immediately.
Parameters:
id(path) β Media item UUIDprofile(query, optional) β Device profile:web(default),generic,mobile-low,mobile-high,tv-4k. Controls the max resolution the variant is downscaled to.
Response 200:
{
"job_id": "1f2e3d4c-....",
"master_url": "/hls/1f2e3d4c-..../master.m3u8",
"hls_url": "/hls/1f2e3d4c-..../master.m3u8",
"dash_url": "/dash/1f2e3d4c-..../manifest.mpd",
"status": "running",
"reused": false
}One CMAF (fMP4) encode produces both the HLS (master_url) and DASH (dash_url) outputs from shared segments β play whichever the client supports.
Response 404: Media item not found Response 503: Maximum concurrent transcodes reached (retry shortly)
GET /api/v1/transcode/{jobId}/status β
Report a transcode job's readiness β used by the client to poll until the first HLS segments exist, then start playback.
Parameters:
jobId(path) β Transcode job UUID
Response 200:
{
"job_id": "1f2e3d4c-....",
"status": "running",
"segments": 3,
"playlist_ready": true,
"progress": 3.0,
"master_url": "/hls/1f2e3d4c-..../master.m3u8",
"dash_url": "/dash/1f2e3d4c-..../manifest.mpd"
}status is one of running, completed, failed, cancelled. Begin playback once playlist_ready is true (or status is completed).
Response 404: Job not found
HLS / DASH delivery routes β
Served from the transcoded CMAF output on disk (no auth header is required, mirroring direct play, so a <video> / hls.js / DASH-player request works). HLS and DASH share the same fMP4 segments from one encode:
| Endpoint | Description |
|---|---|
GET /hls/{jobId}/master.m3u8 | HLS master playlist |
GET /hls/{jobId}/{file} | HLS media playlist / segment (media_N.m3u8, init-N.m4s, chunk-*.m4s) |
GET /dash/{jobId}/manifest.mpd | DASH manifest |
GET /dash/{jobId}/{file} | DASH segment (the shared init-N.m4s / chunk-*.m4s) |
Playback Endpoints β
POST /api/v1/sessions/{id}/progress β
Report playback progress for resume-from-position support.
Auth: Required (Bearer token)
Parameters:
id(path) β Session ID
Request body:
{
"position_ticks": 1234567,
"event": "progress"
}position_ticksβ Current position in ticks (1 tick = 100 nanoseconds; 1 second = 10,000,000 ticks)eventβ One of:start,progress,pause,complete
Response 200:
{
"ok": true
}Session Endpoints β
GET /api/v1/me/sessions β
List all active playback sessions for the authenticated user.
Auth: Required (Bearer token)
Response 200:
{
"sessions": [
{
"id": "sess-001",
"media_id": "550e8400-e29b-41d4-a716-446655440003",
"device_name": "Safari on macOS",
"started_at": "2026-05-19T10:00:00Z",
"position_ticks": 1234567
}
]
}DELETE /api/v1/sessions/{id} β
Terminate a specific playback session (e.g., remote control of another device).
Auth: Required (Bearer token)
Parameters:
id(path) β Session ID
Response 204: Session terminated
Response 404: Session not found
Hub Endpoints β
POST /api/v1/server-claims/new β
Initiate the claim flow. Called by the server (not the user) to start pairing β it is an unauthenticated bootstrap endpoint (the server has no JWT yet) but requires the protocol header Accept-Phlix-Protocol: v1. The request body is a Phlix\Shared\Hub\ClaimRequest (camelCase); the response is a ClaimResponse carrying a short human-readable claim code the user then redeems. See Hub architecture β Pairing protocol for the full flow and DTO shapes.
Auth: None β but Accept-Phlix-Protocol: v1 is required (400 HUB_PROTOCOL_UNSUPPORTED otherwise).
Request body (ClaimRequest):
{
"serverName": "Alice's NAS",
"version": "0.18.0",
"publicKeysJwk": { "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": "...", "kid": "..." }] },
"hostnameCandidates": ["nas.alice.com", "192.168.1.100"],
"protocolVersion": "v1"
}| Field | Type | Notes |
|---|---|---|
serverName | string | Operator-chosen friendly name. |
version | string | Server semver. |
publicKeysJwk | object | JWKS the server publishes for hub-minted token validation. |
hostnameCandidates | string[] | Hostnames/IPs the server thinks it is reachable at. |
protocolVersion | string | Spec version β "v1". |
Response 200 (ClaimResponse):
{
"claimCode": "ABCD-1234",
"expiresIn": 600,
"claimId": "550e8400-e29b-41d4-a716-446655440009",
"hubBaseUrl": "https://hub.phlix.example.com"
}| Field | Type | Notes |
|---|---|---|
claimCode | string | Short human code the user pastes in the SPA to redeem. |
expiresIn | int | Seconds the claim code is valid (default 600). |
claimId | string (UUID) | Opaque token the server stores so it can poll claim status. |
hubBaseUrl | string | Where the server should send heartbeats once enrolled. |
Response 400: HUB_PROTOCOL_UNSUPPORTED (missing/wrong protocol header) or malformed ClaimRequest.
GET /api/v1/server-claims/{claimId} β
Poll claim status. Called by the server while it waits for the user to redeem the code; public, because the server still has no JWT β the claimId UUID is itself the bearer secret. A claimed response returns the one-time enrollment material (the Ed25519 enrollment JWT + the hub JWKS URL).
Auth: None (the unguessable claimId in the path is the secret). Accept-Phlix-Protocol: v1 required.
Parameters:
claimId(path) β the UUID returned by/server-claims/new.
POST /api/v1/server-claims/claim β
Redeem a claim code. Called by the user from the SPA (My Servers, /app/servers) to bind a pending server to their account. This is the only step in the pairing flow that requires user auth.
Auth: Required (Bearer token). Accept-Phlix-Protocol: v1 required.
Request body:
{
"claim_code": "ABCD-1234"
}Response 200:
{
"enrollment_jwt": "eyJ...",
"hub_jwks_url": "https://hub.phlix.example.com/.well-known/jwks.json",
"server_id": "550e8400-e29b-41d4-a716-446655440004"
}Response 404 / 410 / 409: CLAIM_CODE_NOT_FOUND / CLAIM_CODE_EXPIRED / CLAIM_CODE_ALREADY_CLAIMED.
GET /api/v1/me/servers β
List all servers enrolled under the authenticated Hub account.
Auth: Required (Bearer token)
Response 200: Each entry is the ServerInfoDto payload from phlix-shared.
{
"servers": [
{
"serverId": "550e8400-e29b-41d4-a716-446655440004",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"serverName": "Home Server",
"version": "0.18.0",
"lastSeenAt": 1747645200,
"status": "online",
"hostnameCandidates": ["https://192.168.1.100:32400"],
"relayActive": true
}
]
}| Field | Type | Notes |
|---|---|---|
serverId | string (UUID) | Hub-minted ID. |
userId | string (UUID) | Owner. |
serverName | string | From the original claim. |
version | string | Server semver, refreshed by heartbeat. |
lastSeenAt | int | null | UNIX seconds; null when the server has never checked in. |
status | string | One of online, offline, claiming, disabled. |
hostnameCandidates | string[] | Last-known reachable hostnames. |
relayActive | bool | true when a WSS reverse tunnel (entry in relay_sessions with closed_at IS NULL) is currently open. |
GET /api/v1/me/servers/{id}/access-info β
Return the best client-access URL for a single server, plus relay state.
Auth: Required (Bearer token)
Response 200:
{
"server_id": "550e8400-e29b-41d4-a716-446655440004",
"direct_url": "https://192.168.1.100:32400",
"relay_url": null,
"relay_active": true
}direct_url is the first non-empty entry from hostnameCandidates. relay_url is reserved for the relay-URL form (https://{subdomain}.phlix.media) once the relay is fully wired; until then it is null and clients should fall back to direct_url or initiate a relay tunnel via the /relay/{server_id} WebSocket endpoint.
Response 403: {"error":"Forbidden","code":"server.not_owned"} β token does not own this server.
Response 404: {"error":"Not Found","code":"server.not_found"} β no such server.
DELETE /api/v1/me/servers/{id} β
Unbind a claimed server from the authenticated Hub account. Returns 204 on success. Does not uninstall the server software.
Auth: Required (Bearer token)
Response 204: Empty body.
Response 403/404: Same code values as /access-info.
Admin Endpoints (media server) β
Scope: the endpoints in this section are served by the media server (
phlix-server). The hub exposes a different/api/v1/admin/*surface β see Hub admin API below. The hub has no plugin subsystem, so the/admin/pluginsendpoints are server-only.
GET /api/v1/admin/users β
List all users on the server.
Auth: Required (admin Bearer token or API key)
Response 200:
{
"users": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "admin@example.com",
"username": "admin",
"role": "admin",
"created_at": "2026-01-01T00:00:00Z"
}
]
}POST /api/v1/admin/plugins β
Install a plugin from a plugin.json manifest URL.
Auth: Required (admin Bearer token)
Request body:
{
"url": "https://example.com/plugin.json"
}Response 201:
{
"name": "my-plugin",
"version": "1.0.0",
"enabled": false
}Response 400: Invalid plugin manifest or signature
DELETE /api/v1/admin/plugins/{id} β
Uninstall a plugin by name.
Auth: Required (admin Bearer token)
Parameters:
id(path) β Plugin name
Response 204: Plugin removed
Response 404: Plugin not found
GET /api/v1/admin/libraries/{id}/duplicates β
Preview the duplicate groups in one library. The server pages the library's top-level items, buckets them by a canonical key (separator/year/external-id normalized), and returns only the groups with two or more members (singletons excluded). An empty library β or one with no duplicates β returns { "groups": [] } (200, not 404).
Auth: Required (admin Bearer token). 401 unauthenticated, 403 non-admin.
Parameters:
id(path) β Library UUID.
Response 200:
{
"groups": [
{
"canonical_key": "hunterexhunter",
"type": "series",
"library_id": "550e8400-e29b-41d4-a716-446655440001",
"primary": { "id": "β¦", "title": "Hunter x Hunter", "descendant_count": 100 },
"duplicates": [ { "id": "β¦", "title": "Hunter.x.Hunter", "descendant_count": 1 } ]
}
]
}The primary is the group member with the most descendants (ties broken by smaller id); the rest are duplicates. descendant_count lets the UI show how many seasons/episodes (or none, for a movie) hang off each row.
POST /api/v1/admin/media/merge β
Apply a merge: collapse the duplicates into the primary. For a series, episodes are re-parented onto the primary's matching season (re-parent-before-delete), then the empty duplicate season/series shells are deleted. For a movie, richer metadata is gap-filled onto the primary (add-only β non-empty primary fields are never overwritten) and the duplicate row is deleted. The whole operation runs inside one real DB transaction.
Auth: Required (admin Bearer token). 401 unauthenticated, 403 non-admin.
Request body:
{
"primary_id": "550e8400-β¦",
"duplicate_ids": ["β¦", "β¦"]
}Response 200:
{ "moved": 1, "deleted": 2 }movedβ number of children re-parented onto the primary.deletedβ number of empty shell / duplicate rows removed.
Errors:
400βprimary_idempty/missing,duplicate_idsnot a non-empty array, a self-merge (primary id listed induplicate_ids), or a duplicate that is in a different library or of a different type than the primary.404β primary item not found.503β the merge is unavailable because no transaction-capable database connection is bound (the read-onlyβ¦/duplicatespreview is unaffected).
This is the backend for the admin SPA Duplicates page; the same merge logic is exposed offline as the scripts/dedup-series.php CLI.
GET /api/v1/admin/metadata/sources β
List the metadata source names available for the per-media-type priority editor: the built-ins plus any enabled metadata-provider plugin's registered source name.
Auth: Required (admin Bearer token). 401 unauthenticated, 403 non-admin.
Response 200:
{ "sources": ["tmdb", "imdb", "tvdb", "fanart", "local", "anidb", "myanimelist"] }The built-ins (tmdb, imdb, tvdb, fanart, local) are listed first in a stable order, followed by any extra plugin source names from the live SourceRegistry (registered when a metadata-provider plugin is enabled, deregistered on disable). Names are de-duplicated, so a plugin re-using a built-in name does not appear twice. This feeds the Metadata source priority editor.
Hub admin API β
Scope: these endpoints are served by the hub (
phlix-hub), not the media server. They are the JSON backend for the hub's gated Admin console (the Vue SPA at/app/admin/*). All routes are gated by auth + admin middleware (401when unauthenticated,403when authenticated but not an admin). The first user to register is auto-promoted to admin.
The hub admin API is mounted under /api/v1/admin/*. Most read shapes follow the { success, data: β¦ } envelope used by the shared @phlix/ui admin pages.
Logs β
| Method | Path | Description |
|---|---|---|
GET | /api/v1/admin/logs | List the hub's log files. |
GET | /api/v1/admin/logs/tail | Tail a single log file. |
GET | /api/v1/admin/logs/tail-all | Tail all log files merged into one chronological stream. |
Settings β
| Method | Path | Description |
|---|---|---|
GET | /api/v1/admin/settings | Read effective hub settings. |
PUT | /api/v1/admin/settings | Persist hub setting overrides. |
Users β
| Method | Path | Description |
|---|---|---|
GET | /api/v1/admin/users | List users. |
POST | /api/v1/admin/users | Create a user. |
GET | /api/v1/admin/users/{id} | Get one user. |
PUT | /api/v1/admin/users/{id} | Update a user. |
DELETE | /api/v1/admin/users/{id} | Delete a user. |
POST | /api/v1/admin/users/{id}/set-admin | Grant/revoke admin. |
POST | /api/v1/admin/users/{id}/reset-password | Reset a user's password. |
GET | /api/v1/admin/users/{id}/profiles | Per-user profiles β always returns [] on the hub (no profiles subsystem; present for SPA parity). |
Dashboard β
GET /api/v1/admin/dashboard/summary β
Hub-scoped headline counters, aggregated from existing tables.
Response 200:
{
"success": true,
"data": {
"servers": { "total": 12, "online": 9, "offline": 3 },
"active_relay_sessions": 4,
"pending_requests": 2,
"user_count": 37
}
}GET /api/v1/admin/dashboard/activity?limit= β
Recent audit events as the dashboard activity feed. limit caps the number of rows returned.
Response 200:
{
"success": true,
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440010",
"action": "login_success",
"actor": "admin@example.com",
"target": "user:550e8400-β¦",
"created_at": "2026-06-04T12:00:00Z"
}
]
}Media requests β
The hub also exposes an admin queue for member media requests at /api/v1/admin/requests (list) plus /{id}/approve and /{id}/deny. These are documented in full, with the member-facing /api/v1/me/requests surface, in Hub media requests.
Error Codes β
All endpoints may return these standard error codes:
| Code | Meaning |
|---|---|
400 | Bad request β malformed JSON or missing required fields |
401 | Unauthorized β missing or invalid Bearer token |
403 | Forbidden β valid token but insufficient permissions |
404 | Not found β resource does not exist |
422 | Validation error β request body fails validation |
500 | Internal server error |
Error response body:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email address is already in use"
}
}Marker Endpoints β
GET /api/v1/media/{id}/markers β
Returns all markers (intro, outro, chapters) for a media item.
Parameters:
id(path) β Media item ID
Response 200:
{
"intro": {
"start": 0,
"end": 90,
"confidence": 85
},
"outro": {
"start": 2310,
"end": 2400,
"confidence": 80
},
"chapters": [
{ "start": 0, "end": 90, "title": "Intro" },
{ "start": 90, "end": 300, "title": "Chapter 1" }
]
}Notes:
introandoutroarenullif no marker is detectedchaptersis an empty array if no chapters are defined- Read from formal marker columns first, falls back to metadata_json candidates
GET /api/v1/media/{id}/markers/intro β
Returns the intro marker for a media item.
Parameters:
id(path) β Media item ID
Response 200:
{
"start": 0,
"end": 90,
"confidence": 85
}Response 404: Intro marker not found for this media item
GET /api/v1/media/{id}/markers/outro β
Returns the outro marker for a media item.
Parameters:
id(path) β Media item ID
Response 200:
{
"start": 2310,
"end": 2400,
"confidence": 80
}Response 404: Outro marker not found for this media item
GET /api/v1/shows/{id}/markers/bulk β
Returns markers for all episodes of a show.
Parameters:
id(path) β Show/series media item ID
Response 200:
{
"show_id": "show-123",
"episodes": [
{
"id": "ep-1",
"name": "Episode 1",
"markers": {
"intro": { "start": 0, "end": 90, "confidence": 85 },
"outro": null,
"chapters": []
}
}
]
}Notes:
- Episodes are enumerated via
parent_idrelationship - Introduced in Step F.3 (v0.12.0)
Playback Endpoints β
GET /api/v1/media/{id}/playback-info β
Returns playback information including stream URL and skip button markers.
Parameters:
id(path) β Media item ID
Response 200:
{
"playback_info": {
"id": "abc123",
"name": "S1E01 - The Beginning",
"type": "episode",
"media_sources": [
{
"id": "default",
"container": "mkv",
"path": "/mnt/media/shows/show1/s01e01.mkv",
"direct_play": true
}
],
"markers": {
"skip_intro_start": 10,
"skip_intro_end": 90,
"skip_outro_start": 2340,
"skip_outro_end": 2520
}
}
}Fields:
markers.skip_intro_start(int|null) β Intro start in seconds, null if no intromarkers.skip_intro_end(int|null) β Intro end in seconds, null if no intromarkers.skip_outro_start(int|null) β Outro start in seconds, null if no outromarkers.skip_outro_end(int|null) β Outro end in seconds, null if no outro
Notes:
- Clients should show "Skip Intro" button when position is between
skip_intro_startandskip_intro_end - Clients should show "Skip Outro" button when position is between
skip_outro_startandskip_outro_end - Clicking a skip button should seek to the corresponding
_endposition - Marker fields are
nullwhen no marker is detected - Introduced in Step F.4 (v0.12.0)
Marker Data Model β
IntroMarker / OutroMarker β
| Field | Type | Description |
|---|---|---|
start | int | Start time in seconds |
end | int | End time in seconds |
confidence | int | Detection confidence 0-100 |
ChapterMarker β
| Field | Type | Description |
|---|---|---|
start | int | Chapter start time in seconds |
end | int | Chapter end time in seconds |
title | string|null | Optional chapter title |
Database Storage β
Markers are stored in media_items table columns:
intro_start_secondsβ INT UNSIGNED NULLintro_end_secondsβ INT UNSIGNED NULLoutro_start_secondsβ INT UNSIGNED NULLoutro_end_secondsβ INT UNSIGNED NULLchapters_jsonβ JSON NULL
Before formal column population, markers are cached in metadata_json as:
intro_candidateβ{ start_seconds, end_seconds, fingerprint, confidence }outro_candidateβ{ start_seconds, end_seconds, fingerprint, confidence }
Use MarkerService.promoteCandidates() to migrate candidates to formal columns.
OPDS Feed Endpoints (Book Library) β
OPDS 1.2 compliant feeds for third-party OPDS client integration.
GET /opds/v1.2 β
Returns the root OPDS catalog feed.
Auth: Required (Bearer token)
Response 200:
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:opds="http://opds-spec.org/2010/catalog">
<title>Phlix Library</title>
<updated>2024-01-15T10:30:00Z</updated>
<id>urn:phlix:library:root</id>
<link rel="self" href="http://localhost:8080/opds/v1.2" type="application/atom+xml;profile=opds-catalog"/>
<link rel="alternate" href="http://localhost:8080/opds/v1.2/libraries" type="application/atom+xml;profile=opds-catalog;kind=navigation"/>
</feed>GET /opds/v1.2/libraries β
Returns a navigation feed listing all book libraries.
Auth: Required (Bearer token)
Response 200: OPDS Atom XML with navigation links to library acquisition feeds.
GET /opds/v1.2/libraries/{id} β
Returns an acquisition feed listing all books in a library.
Auth: Required (Bearer token)
Parameters:
id(path) β Library IDoffset(query) β Pagination offset (default: 0)limit(query) β Maximum items per page (default: 50, max: 100)
Response 200: OPDS Atom XML with book entries, pagination links (previous/next).
Book Endpoints β
GET /api/v1/books β
Returns a list of all books.
Auth: Required (Bearer token)
Query parameters:
library_id(optional) β Filter by librarylimit(optional) β Maximum items (default: 50)offset(optional) β Pagination offset (default: 0)
Response 200:
{
"books": [
{
"id": "book-123",
"name": "Book Title",
"type": "book",
"path": "/path/to/book.epub",
"metadata": {
"title": "Book Title",
"author": "Author Name"
}
}
],
"limit": 50,
"offset": 0
}GET /api/v1/books/{id} β
Returns a single book by ID.
Auth: Required (Bearer token)
Parameters:
id(path) β Book ID
Response 200:
{
"book": {
"id": "book-123",
"name": "Book Title",
"type": "book",
"path": "/path/to/book.epub",
"metadata": {}
}
}Response 404: Book not found
GET /api/v1/books/{id}/cover β
Returns the book's cover image.
Auth: Required (Bearer token)
Parameters:
id(path) β Book ID
Response 200: JPEG/PNG image with appropriate Content-Type header.
Response 404: Cover not found or book not found
GET /api/v1/books/{id}/read β
Returns an HTML reader page for the book.
Auth: Required (Bearer token)
Parameters:
id(path) β Book ID
Response 200: HTML page with embedded book reader.
Response 404: Book not found
GET /api/v1/books/{id}/download β
Returns the book file for download.
Auth: Required (Bearer token)
Parameters:
id(path) β Book ID
Response 200: Book file with Content-Disposition: attachment header.
- EPUB:
application/epub+zip - PDF:
application/pdf - CBZ:
application/vnd.comicbook+zip
Response 404: File not found
Music Endpoints β
Music library browsing with ID3v2/MP4/Vorbis tag harvesting and MusicBrainz metadata enrichment.
GET /api/v1/music/artists β
List all music artists.
Auth: Required (Bearer token)
Query parameters:
limit(optional) β Maximum items (default: 50)offset(optional) β Pagination offset (default: 0)
Response 200:
{
"artists": [
{
"mbid": "550e8400-e29b-41d4-a716-446655440001",
"name": "Artist Name",
"album_count": 5,
"track_count": 42
}
],
"limit": 50,
"offset": 0
}GET /api/v1/music/artists/{mbid} β
Get artist details with albums.
Auth: Required (Bearer token)
Parameters:
mbid(path) β MusicBrainz ID for the artist
Response 200:
{
"artist": {
"mbid": "550e8400-e29b-41d4-a716-446655440001",
"name": "Artist Name",
"sort_name": "Artist Name",
"albums": [
{
"mbid": "550e8400-e29b-41d4-a716-446655440002",
"name": "Album Name",
"year": 2024,
"track_count": 10
}
]
}
}Response 404: Artist not found
GET /api/v1/music/albums β
List all music albums.
Auth: Required (Bearer token)
Query parameters:
artist_mbid(optional) β Filter by artist MusicBrainz IDlimit(optional) β Maximum items (default: 50)offset(optional) β Pagination offset (default: 0)
Response 200:
{
"albums": [
{
"mbid": "550e8400-e29b-41d4-a716-446655440002",
"name": "Album Name",
"artist_mbid": "550e8400-e29b-41d4-a716-446655440001",
"artist_name": "Artist Name",
"year": 2024,
"track_count": 10
}
],
"limit": 50,
"offset": 0
}GET /api/v1/music/albums/{mbid} β
Get album details with track listing.
Auth: Required (Bearer token)
Parameters:
mbid(path) β MusicBrainz ID for the album
Response 200:
{
"album": {
"mbid": "550e8400-e29b-41d4-a716-446655440002",
"name": "Album Name",
"artist_mbid": "550e8400-e29b-41d4-a716-446655440001",
"artist_name": "Artist Name",
"year": 2024,
"genre": "Rock",
"tracks": [
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"title": "Track Title",
"track_number": 1,
"duration_secs": 245,
"path": "/mnt/media/music/Artist/Album/01 - Track Title.flac"
}
]
}
}Response 404: Album not found
GET /api/v1/music/tracks β
List all music tracks (paginated).
Auth: Required (Bearer token)
Query parameters:
album_mbid(optional) β Filter by album MusicBrainz IDartist_mbid(optional) β Filter by artist MusicBrainz IDlimit(optional) β Maximum items (default: 50)offset(optional) β Pagination offset (default: 0)
Response 200:
{
"tracks": [
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"title": "Track Title",
"artist_name": "Artist Name",
"album_name": "Album Name",
"track_number": 1,
"duration_secs": 245
}
],
"limit": 50,
"offset": 0
}GET /api/v1/music/tracks/{id} β
Get single track details.
Auth: Required (Bearer token)
Parameters:
id(path) β Track ID
Response 200:
{
"track": {
"id": "550e8400-e29b-41d4-a716-446655440003",
"title": "Track Title",
"artist_name": "Artist Name",
"album_name": "Album Name",
"track_number": 1,
"disc_number": 1,
"duration_secs": 245,
"bitrate": 1411,
"sample_rate": 44100,
"channels": 2,
"path": "/mnt/media/music/Artist/Album/01 - Track Title.flac",
"metadata": {
"title": "Track Title",
"artist": "Artist Name",
"album": "Album Name",
"year": 2024,
"genre": "Rock"
}
}
}Response 404: Track not found
GET /api/v1/music/now-playing β
Get current playback state.
Auth: Required (Bearer token)
Response 200:
{
"now_playing": {
"track_id": "550e8400-e29b-41d4-a716-446655440003",
"title": "Track Title",
"artist_name": "Artist Name",
"album_name": "Album Name",
"position_secs": 120,
"duration_secs": 245,
"playing": true
}
}Notes:
- Returns
now_playing: nullwhen nothing is playing position_secsindicates current playback positionplayingistruefor playing,falsefor paused
Audiobook Endpoints β
Chapter-aware audiobook playback with per-user progress tracking.
GET /api/v1/audiobooks β
List all audiobooks.
Auth: Required (Bearer token)
Query parameters:
library_id(optional) β Filter by librarylimit(optional) β Maximum items (default: 50)offset(optional) β Pagination offset (default: 0)
Response 200:
{
"audiobooks": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"name": "Audiobook Title",
"author": "Author Name",
"narrator": "Narrator Name",
"duration_secs": 36000,
"chapter_count": 25,
"progress_percent": 45.5
}
],
"limit": 50,
"offset": 0
}GET /api/v1/audiobooks/{id} β
Get audiobook with chapters.
Auth: Required (Bearer token)
Parameters:
id(path) β Audiobook ID
Response 200:
{
"audiobook": {
"id": "550e8400-e29b-41d4-a716-446655440001",
"name": "Audiobook Title",
"author": "Author Name",
"narrator": "Narrator Name",
"duration_secs": 36000,
"chapters": [
{
"index": 0,
"title": "Chapter 1: The Beginning",
"start_ms": 0,
"end_ms": 1440000
}
]
}
}Response 404: Audiobook not found
GET /api/v1/audiobooks/{id}/chapters β
Get chapter list for an audiobook.
Auth: Required (Bearer token)
Parameters:
id(path) β Audiobook ID
Response 200:
{
"audiobook_id": "550e8400-e29b-41d4-a716-446655440001",
"chapters": [
{
"index": 0,
"title": "Chapter 1: The Beginning",
"start_ms": 0,
"end_ms": 1440000
},
{
"index": 1,
"title": "Chapter 2: The Journey",
"start_ms": 1440000,
"end_ms": 2880000
}
]
}GET /api/v1/audiobooks/{id}/progress β
Get authenticated user's progress for an audiobook.
Auth: Required (Bearer token)
Parameters:
id(path) β Audiobook ID
Response 200:
{
"audiobook_id": "550e8400-e29b-41d4-a716-446655440001",
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"position_ms": 1800000,
"current_chapter_index": 1,
"completed_chapters": [0],
"percent_complete": 5.0,
"last_played_at": 1747645200
}Response 404: No progress found for this user/audiobook combination
POST /api/v1/audiobooks/{id}/progress β
Save playback progress for the authenticated user.
Auth: Required (Bearer token)
Parameters:
id(path) β Audiobook ID
Request body:
{
"position_ms": 1800000,
"current_chapter_index": 1
}Response 200:
{
"ok": true,
"percent_complete": 5.0
}Notes:
- Progress is saved every 10 seconds during playback
position_msis the current position within the chapter (milliseconds)current_chapter_indexis 0-based
GET /api/v1/audiobooks/{id}/read β
Returns an HTML player page for the audiobook.
Auth: Required (Bearer token)
Parameters:
id(path) β Audiobook ID
Response 200: HTML page with embedded audiobook player.
Response 404: Audiobook not found
GET /api/v1/audiobooks/{id}/stream β
Stream audiobook file directly as raw bytes. Supports HTTP Range requests for seeking and resume.
Auth: Required (Bearer token)
Parameters:
id(path) β Audiobook ID
Request headers (optional):
Rangeβ Byte range request (e.g.,bytes=1000-5000). Returns 206 Partial Content.
Response 200:
- Content-Type: Detected from file extension (
audio/mp4,audio/mpeg,audio/aac, etc.) - Accept-Ranges:
bytes - Content-Length: Total file size in bytes
Response 206 (Partial Content):
- Content-Type: Detected from file extension
- Content-Range:
bytes {start}-{end}/{total} - Content-Length: Bytes served in this range
Response 403: Path validation failed (invalid path traversal attempt)
Response 404: Audiobook not found
Example range request:
GET /api/v1/audiobooks/550e8400-e29b-41d4-a716-446655440001/stream
Range: bytes=1000-5000Example response headers (200):
Accept-Ranges: bytes
Content-Length: 36000000
Content-Type: audio/mp4Example response headers (206):
Accept-Ranges: bytes
Content-Range: bytes 1000-5000/36000000
Content-Length: 4001
Content-Type: audio/mp4Notes:
- Returns raw audio bytes, not base64-encoded data
- Supports M4B, M4A, MP3, AAC, OGG, FLAC, WAV formats
- MIME type detected from file extension
- Path validation prevents directory traversal attacks
- Clients should send
Rangeheader for seeking/resume support
Photo Endpoints β
Photo browsing with EXIF metadata extraction, album organization, and slideshow functionality.
GET /api/v1/photo/albums β
List all photo albums (grouped by date taken).
Auth: Required (Bearer token)
Query parameters:
limit(optional) β Maximum items (default: 50)offset(optional) β Pagination offset (default: 0)
Response 200:
{
"albums": [
{
"id": "album-2024-05-15",
"date": "2024-05-15",
"photo_count": 42,
"cover_photo": {
"id": "550e8400-e29b-41d4-a716-446655440001",
"thumbnail_url": "/api/v1/photo/photos/550e8400-e29b-41d4-a716-446655440001/thumbnail?w=300&h=300&fit=cover"
}
}
],
"limit": 50,
"offset": 0
}GET /api/v1/photo/albums/{id} β
Get specific album with photos.
Auth: Required (Bearer token)
Parameters:
id(path) β Album ID (date string in YYYY-MM-DD format)
Response 200:
{
"album": {
"id": "album-2024-05-15",
"date": "2024-05-15",
"photos": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"name": "IMG_0001.jpg",
"width": 4032,
"height": 3024,
"date_taken_unix": 1715784000,
"thumbnail_url": "/api/v1/photo/photos/550e8400-e29b-41d4-a716-446655440001/thumbnail?w=300&h=300&fit=cover"
}
]
}
}Response 404: Album not found
GET /api/v1/photo/photos β
List all photos.
Auth: Required (Bearer token)
Query parameters:
album_id(optional) β Filter by albumlimit(optional) β Maximum items (default: 50)offset(optional) β Pagination offset (default: 0)
Response 200:
{
"photos": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"name": "IMG_0001.jpg",
"width": 4032,
"height": 3024,
"date_taken_unix": 1715784000,
"thumbnail_url": "/api/v1/photo/photos/550e8400-e29b-41d4-a716-446655440001/thumbnail?w=300&h=300&fit=cover"
}
],
"limit": 50,
"offset": 0
}GET /api/v1/photo/photos/{id} β
Get photo with full EXIF data.
Auth: Required (Bearer token)
Parameters:
id(path) β Photo ID
Response 200:
{
"photo": {
"id": "550e8400-e29b-41d4-a716-446655440001",
"name": "IMG_0001.jpg",
"path": "/mnt/media/photos/2024-05-15/IMG_0001.jpg",
"width": 4032,
"height": 3024,
"date_taken_unix": 1715784000,
"exif": {
"camera_make": "Apple",
"camera_model": "iPhone 15 Pro",
"lens": "iPhone 15 Pro back camera 6.765mm f/1.78",
"aperture": "f/1.78",
"iso": 100,
"shutter_speed": "1/1234",
"focal_length": "6.765mm",
"gps_lat": 37.7749,
"gps_lng": -122.4194,
"gps_alt": 10.5
}
}
}Response 404: Photo not found
GET /api/v1/photo/photos/{id}/thumbnail β
Get resized thumbnail.
Auth: Required (Bearer token)
Parameters:
id(path) β Photo IDw(query, optional) β Width in pixels (default: 300)h(query, optional) β Height in pixels (default: 300)fit(query, optional) β Fit mode:cover(crop to fill, default) orcontain(letterbox)
Response 200: JPEG image with appropriate Content-Type header.
Response 404: Photo not found
Notes:
- Thumbnails are generated on-demand using PHP's GD library
- Served with
Cache-Control: public, max-age=86400(1 day)
GET /api/v1/photo/photos/{id}/full β
Get full-resolution photo.
Auth: Required (Bearer token)
Parameters:
id(path) β Photo ID
Response 200: Original image file (JPEG/PNG/TIFF/WebP/HEIC) with appropriate Content-Type header.
Response 404: Photo not found
Notes:
- Served with
Cache-Control: public, max-age=31536000(1 year) - HEIC/HEIF format requires ImageMagick extension; returns 500 if unavailable
GET /api/v1/photo/slideshow β
Get slideshow data for an album.
Auth: Required (Bearer token)
Query parameters:
album_id(optional) β Album ID; if omitted, uses most recent albuminterval(query, optional) β Seconds between slides (default: 5)
Response 200:
{
"slideshow": {
"album_id": "album-2024-05-15",
"interval": 5,
"photos": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"full_url": "/api/v1/photo/photos/550e8400-e29b-41d4-a716-446655440001/full",
"thumbnail_url": "/api/v1/photo/photos/550e8400-e29b-41d4-a716-446655440001/thumbnail?w=300&h=300",
"caption": "Apple iPhone 15 Pro - 2024-05-15"
}
]
}
}Notes:
- Returns photos in chronological order
- Caption shows camera info and date taken