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
Rate limiting (SV-4.15).
register,refresh, and the WebAuthn loginstart/finishendpoints (and the public JWKS endpoint) are rate-limited per surface. Over-limit requests return429 Too Many Requestswith aRetry-Afterheader and body{"error":"Too Many Requests","code":"rate_limited"}. Limits are tunable viaRATE_LIMIT_*and keyed on the real client IP — setTRUSTED_PROXIESbehind a proxy. See Auth rate limiting and Security hardening.
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
Movie-library option — autoCollections: movie libraries accept an optional autoCollections field (a bare boolean, or { "enabled": bool }) at the body top level — or nested in options — that gates the scanner's TMDB box-set auto-collection generation. It is normalised to { "enabled": bool }, stored in the library's options blob, and defaults to enabled when absent. The same field is accepted on the library update (PUT /api/v1/libraries/{id}), where it is merged into the existing options. Every library object returned by GET /api/v1/libraries carries the effective value under a top-level auto_collections: { "enabled": bool } block. See Auto-generated collections.
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: re-walk the library tree, index files at paths that are not yet in the catalogue, backfill missing source metadata on the already-existing rows whose type is video, movie, episode, audio or audiobook (photo and book rows get nothing), then prune the items whose source file is gone. Same contract as scan, with a rescan-typed job.
A rescan is non-destructive for items whose file is still where the catalogue says it is — it does not delete them, their watch history or their fetched metadata.
A file that was MOVED keeps its identity (S158)
A parent-less item — movie, video, photo, book, audiobook — whose file was moved without being renamed is matched by a path-independent canonical key. The scan records the new location and the prune in the same job re-points the existing row at it instead of deleting it, so the item keeps its UUID, its user_item_data (favorite, rating, like level, watched) and its resume position. Episodes were never affected.
Two cases still lose the row: a move that also renames the file (a different name is a different canonical key), and the standalone prune job, which does not scan and so has no candidate to adopt. Full detail: Moving a file.
The "re-read every file" half applies to music libraries only
rescan passes a readEveryFile flag that only the music scanner consumes; every other scanner has no skip index for it to switch off. On a music library the flag makes the scan open and tag-read every track, which is what repairs a track filed under the wrong album or artist after a retag — and it can take hours. On a movie, TV, photo, book or audiobook library an already-indexed path is not re-parsed or re-matched at all; the scan does a missing-source-metadata backfill on that row and moves on — and even that backfill is type-gated to video / movie / episode / audio / audiobook, so on a photo or book library the existing-item branch does nothing at all.
On a movie or TV library, use match-metadata for an item that was never matched and the single-item match for one that matched the wrong title — see Fixing a wrong match. On a photo, book or audiobook library there is no re-match operation at all; those job types skip every row of those types. See what Match metadata skips.
See Scan vs Rescan for measured music-library figures.
Auth: Admin (Bearer token)
Response 202:
{
"job_id": "550e8400-e29b-41d4-a716-446655440100",
"status": "queued",
"message": "Library rescan queued: every file will be re-read. This repairs tracks filed under the wrong album or artist and can take hours on a large music library. Use Scan for an incremental refresh."
}Response 404: Library not found
POST /api/v1/libraries/{id}/match-metadata
Enqueue a background metadata match as a metadata-typed job. Resolves details from each item's stored name/year/external ids and, on a hit, merges the result into metadata_json and stamps metadata_refreshed_at. Items that already carry that stamp are skipped, so this is the operation for unmatched items — including on movie and TV libraries, where a rescan does not revisit an already-indexed row.
It visits movie, video and series rows only
Every other media_items.type — season, episode, artist, album, track, photo, book, audiobook — is skipped before any provider is consulted. Seasons and episodes are still enriched, but through their parent series. On a photo, book or audiobook library this job reaches completed having processed zero items. See what Match metadata skips.
Auth: Admin (Bearer token)
Response 202:
{
"job_id": "550e8400-e29b-41d4-a716-446655440101",
"status": "queued",
"message": "Metadata match queued"
}Response 404: Library not found
POST /api/v1/libraries/{id}/refresh-metadata
Enqueue a forced re-match as a metadata_refresh-typed job: the same matcher with force-refresh enabled, so items that were already matched (metadata_refreshed_at IS NOT NULL) are re-processed rather than skipped. Use it to backfill fields added by a newer release, or to pick up a changed provider priority. An addition to match-metadata, not a replacement.
It runs the same per-item filter as match-metadata: movie, video and series rows only.
It does not undo a match that resolved to the wrong title
For a movie the matcher re-seeds the resolve from the item's own stored external_ids — the ids the bad match wrote — and the movie resolver searches by title only when no IMDb id is present, resolving by the id when one is. For a series the resolver takes no ids but re-runs the same title/year search, so it returns the same record. Either way the job reports success and the wrong title comes back.
Use POST /api/v1/media/{id}/match/apply for one item, or clear-metadata → match-metadata for a movie library seeded off bad ids. See Fixing a wrong match.
If the metadata.overwrite_existing setting is off, an item that already has metadata is skipped wholesale (there is no per-field provenance to merge against). At the shipped default it is on and nothing is skipped.
Auth: Admin (Bearer token)
Response 202:
{
"job_id": "550e8400-e29b-41d4-a716-446655440102",
"status": "queued",
"message": "Metadata refresh queued"
}Response 404: Library not found
POST /api/v1/libraries/{id}/clear-metadata
Enqueue a clear_metadata-typed job. For every item in the library it strips the provider-fetched keys from metadata_json — artwork/trailer URLs, overview and tagline, cast/crew, genres and tags, ratings and votes, provider dates and alternate titles, and the external identifiers (tmdb_id, imdb_id, tvdb_id, external_ids) — and NULLs metadata_refreshed_at so the item counts as un-matched again.
No rows are deleted. Paths, filename-derived titles, types, the series/season hierarchy, user_item_data and watch history are preserved, as are the filesystem/probe-derived keys (name, year, season, episode, canonical_key, source, duration, streams).
Pair it with match-metadata to re-match a movie library that was seeded off bad external ids. It is library-wide with no dry run, so it discards correct matches along with the wrong ones. See Clear a library's metadata.
Auth: Admin (Bearer token)
Response 202:
{
"job_id": "550e8400-e29b-41d4-a716-446655440103",
"status": "queued",
"message": "Metadata clear 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 -2..2) — the current user's thumbs value on a signed axis:-2= strongly dislike,-1= dislike,0= not set,1= like,2= love. 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
GET /api/v1/media/most-watched
The Most Watched rail — the media items most-watched across the whole server. This is a global "trending" list (server-wide popularity), not a per-user history: every signed-in user sees the same list. It reuses the same all-time, cross-user aggregate the admin Top Media report reads, ordered by play count (descending). Playback events are counted from the finish signal (POST /api/v1/sessions/{id}/complete) and progress reporting.
Auth: Required (Bearer token) — same audience as GET /api/v1/media.
Query parameters:
limit(optional) — number of items to return. Default20, clamped to a hard ceiling of100.
Response 200:
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"name": "The Popular One",
"type": "movie",
"poster_url": "https://…"
}
],
"total": 1,
"limit": 20,
"offset": 0
}Items are shaped exactly like GET /api/v1/media (poster/artwork signed URLs are re-minted at response time and the type is the item's real media type). Since-deleted items are silently dropped, so the rail never references a missing row. offset is always 0 (this is a fixed top-N rail, not a paginated list) and total reflects the number of returned items.
The endpoint is available for any client to render a Most Watched row. Wiring it into the web home screen as a visible rail is a separate, forthcoming step; until then this endpoint can be consumed directly.
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, all HTTP verbs are forwarded —
POST .../favorite,PUT .../rating, andDELETE .../favoriteall reach the server normally. The relay proxy no longer restrictively allowlistsGET/HEADonly (phlix-hubApplication.phplines 479–495).
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 thumbs value for the item.
Since: 0.57.0
Auth: Required
Body:
{ "level": 2 }level(int -2..2, required) — the signed thumbs axis:-2= strongly dislike,-1= dislike,0= not set,1= like,2= love. Thelevelfield is required (there is no "clear" / null branch — set0to unset). The range is enforced in PHP (UserItemDataRepository::MIN_LIKE/MAX_LIKE); theTINYINTcolumn carries no DBCHECKconstraint.like_levelis a separate axis fromfavorite(boolean) andrating(1-10).
Response 200: { "message": "Love level saved" }Response 400: two distinct bodies — a missing/non-integer level returns {"error": "level must be an integer between -2 and 2"}; an integer outside -2..2 passes that guard and is rejected by the repository with {"error": "like_level must be between -2 and 2 (inclusive), got 3"}Response 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).
GET /api/v1/users/me/continue-watching
Also available as GET /api/v1/me/continue-watching — the same handler (PlaybackController::getContinueWatching) backs both routes and returns the identical shape.
List media items the current user has started but not finished (the Continue Watching rail on the home screen). Items with percent_complete >= 95 are excluded — they are considered finished. The same title watched across several sessions/devices is de-duplicated to a single row (the most recently updated) via a ROW_NUMBER() window before the limit is applied.
How an item leaves this rail. Besides crossing the 95% threshold, a title also drops out the moment the player sends the explicit finish signal (
POST /api/v1/sessions/{id}/complete) — the web SPA player and mini-player fire this automatically on the mediaendedevent, so a fully-watched title clears itself with no manual "mark watched" step. Native clients (Roku, mobile, Tizen, Windows) do not yet send this signal, so a title finished on those clients may linger until it independently crosses 95% on a progress tick. This is a tracked follow-up.
Auth: Required
Response 200:
Each entry is a shaped media item (produced by MediaItemShaper::shape(), the same shape the /app SPA MediaCard and console clients render), with playback-progress fields re-attached at the top level. Top-level id is the media item id (not the playback-state id), so it navigates directly to the detail page. For episodes, poster_url / poster_srcset resolve to the series poster (falling back to the season poster, then the episode's own poster) rather than the TMDB still frame, so the rail shows real cover art.
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"name": "Attack on Titan",
"type": "episode",
"poster_url": "https://image.tmdb.org/t/p/w500/series-poster.jpg",
"poster_srcset": "https://.../w342/series-poster.jpg 342w, https://.../w500/series-poster.jpg 500w",
"runtime": 24,
"year": 2023,
"genres": ["Animation", "Action"],
"rating": "TV-MA",
"parent_id": "season-uuid",
"season_number": 4,
"episode_number": 75,
"episode_title": "Sparks",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-02T00:00:00+00:00",
"media_item_id": "550e8400-e29b-41d4-a716-446655440003",
"position_ticks": 4200000000,
"duration_ticks": 14400000000,
"metadata": { "poster_url": "https://image.tmdb.org/t/p/w500/series-poster.jpg", "...": "..." }
}
]
}The response is a bare { "items": [...] } object — there is no limit, offset, or total field. Key fields:
| Field | Notes |
|---|---|
id | Media item id (top level). Equals media_item_id. Use for detail navigation. |
poster_url | Series poster for episodes (resolved before shaping); fallback season → own poster. |
poster_srcset | Responsive srcset string when an ArtworkStorage cache exists (SV-3.4). |
runtime | Runtime in minutes (from metadata). |
year, rating, genres | Standard shaped fields (rating = official/content rating). |
parent_id | Parent container id — the season for episodes. |
position_ticks | Raw playback position in ticks (re-attached; the SPA useResumeSync reads this). |
duration_ticks | Raw total duration in ticks (re-attached). |
media_item_id | Media item id; the rating gate filters on this key, and the console fromContinueWatching mapper reads it. |
metadata | Full metadata map, preserved (episode entries carry the resolved series poster_url). |
The account-level rating gate is applied per active profile: over-cap titles (by effective rating) are dropped, keyed on
media_item_id.
Up Next for series. When an episode is completed, the next episode in the series is automatically surfaced at the top of Continue Watching (if it exists and is available). See Recommendations & Discovery → Up Next.
GET /api/v1/users/me/next-up
The Next Up rail — a sibling to Continue Watching. Where Continue Watching lists the in-progress items you can resume, Next Up lists, for each series you have started, the single next episode to play: the fresh episode to begin next rather than one already part-watched. It is the classic Plex/Jellyfin "Next Up" row.
Auth: Required
Query parameters:
limit(optional) — number of series to return a next episode for. Default20, clamped to1-50. (Internally the server scans up tomax(limit × 3, 50)of the most-recently-touched started series so a run of finished series does not starve the rail; the returned list is still capped atlimit.)
How the next episode is chosen. For each series the profile has started (most-recently watched series first), the server looks at the profile's playback history for that series and picks one episode:
- An in-progress episode (playing/paused, between 0% and 95% of its duration) resumes that episode.
- A finished episode (stopped at position 0, or watched to ≥95% of its duration) advances to the next numbered episode, rolling into the next numbered season when the current season is exhausted.
- A series whose episodes are all watched yields no entry (nothing is left to play next).
- Only numbered seasons are walked — Specials / season-less content are excluded from the ordering.
The watched/in-progress signal comes only from playback state (the same source the live Continue Watching rail reads) — the manual "mark watched" flag and the legacy watch-history table are deliberately not consulted. Results pass the active profile's parental rating gate: over-cap episodes (by effective rating) are dropped for a gated profile, while the account owner sees the unfiltered list.
Response 200:
Each entry is a shaped media item (produced by MediaItemShaper::shape(), the same shape Continue Watching returns), so S37's home rail can render it identically. For episodes, poster_url / poster_srcset resolve to the series poster (falling back to the season poster, then the episode's own poster). Because a Next-Up pick is a fresh episode, position_ticks and duration_ticks are always 0. Two extra keys carry the series context so the rail can label "Next Up: <Series> S02E01".
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440007",
"name": "The Wolf and the Lion",
"type": "episode",
"poster_url": "https://image.tmdb.org/t/p/w500/series-poster.jpg",
"poster_srcset": "https://.../w342/series-poster.jpg 342w, https://.../w500/series-poster.jpg 500w",
"runtime": 55,
"year": 2011,
"genres": ["Drama", "Fantasy"],
"rating": "TV-MA",
"parent_id": "season-uuid",
"season_number": 1,
"episode_number": 5,
"episode_title": "The Wolf and the Lion",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-02T00:00:00+00:00",
"media_item_id": "550e8400-e29b-41d4-a716-446655440007",
"position_ticks": 0,
"duration_ticks": 0,
"series_id": "series-uuid",
"series_name": "Game of Thrones",
"metadata": { "poster_url": "https://image.tmdb.org/t/p/w500/series-poster.jpg", "...": "..." }
}
]
}The response is a bare { "items": [...] } object — there is no limit, offset, or total field. In addition to every field the Continue Watching item carries, Next Up adds:
| Field | Notes |
|---|---|
series_id | Id of the parent series the episode belongs to. |
series_name | Display name of the parent series (for the rail label). |
position_ticks | Always 0 — a Next-Up pick is a fresh episode, not a resume. |
duration_ticks | Always 0 for the same reason. |
media_item_id | The episode's media item id; the rating gate filters on this key. |
Response (no active profile): 200 with an empty list — { "items": [] }.
Response 401: unauthenticated (no Bearer token).
Response 503: the watch-history service is not configured on this server.
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).
Client capability negotiation (SV-3.3). Clients may send an
X-Phlix-Client-Capabilitiesrequest header — a JSON codec-support map (e.g.{"eac3":false}) — on playback-info requests. When present, the server'sdirect_playverdict is set from whether the client can decode the item's audio codec (a client that can't decode the audio is steered to transcode); an absent/empty/malformed header keeps the prior always-truebehavior. See Player Quality & Audio → Client Capability Negotiation.
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",
"status": "running",
"reused": false
}The on-demand pipeline currently produces HLS only — a multi-variant ABR ladder played via hls.js (or native HLS on Safari/iOS). It does not emit a DASH manifest, so the response no longer includes a dash_url (removed in updates.md #11 / S11: the advertised /dash/{job}/manifest.mpd is never written and always 404'd). Real DASH output is tracked for a later milestone (updates.md #57 / S56-S60).
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"
}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 delivery routes
Served from the transcoded HLS output on disk (no auth header is required, mirroring direct play, so a <video> / hls.js request works):
| Endpoint | Description |
|---|---|
GET /hls/{jobId}/master.m3u8 | HLS master playlist (multi-variant ABR ladder) |
GET /hls/{jobId}/{file} | HLS media playlist / segment (media_v{rung}.m3u8, seg-v{rung}-NNNNN.ts) |
DASH is not currently available. The
/dash/{jobId}/manifest.mpdand/dash/{jobId}/{file}routes are registered but the on-demand transcode pipeline does not populate a DASH job directory (it emits HLS only), so those routes return404today. The DASH server code is reserved for real DASH support tracked as updates.md #57 / S56-S60. Do not rely on a DASH manifest URL.
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
}POST /api/v1/sessions/{id}/complete
Explicit playback-finished signal. Progress ticks alone only ever leave a session in a playing/paused state, so a finished title would otherwise linger in Continue Watching and its watch-time stats would never finalize. A client player POSTs here when the media reaches its natural end (or is deliberately stopped) to run the server-side finalize path: the item is removed from Continue Watching and its duration_seconds + playback-stats event are finalized (feeding Top Users watch time and the Most Watched rail).
Auth: Required (Bearer token) — the authenticated user must own the session (same posture as POST .../progress).
Parameters:
id(path) — Session ID (fromPOST /api/v1/sessions)
Request body:
{
"media_item_id": "550e8400-e29b-41d4-a716-446655440003",
"reached_end": true
}media_item_id(string, required) — the media item that just finished.reached_end(bool, optional, defaulttrue):true— mark the item watched: theplayback_staterow is set tostoppedwithposition_ticks = 0, the item leaves Continue Watching, and the stats event is recorded as completed.false— clear the resume point: theplayback_staterow is deleted and the stats event is recorded as not completed (also removes the item from Continue Watching).
Response 200:
{
"message": "Playback completed",
"reached_end": true
}Response 400: {"error": "Missing required field: media_item_id"}Response 403: {"error": "Forbidden"} — the session belongs to another user Response 404: {"error": "Session not found"}
Which clients send this today
The web SPA sends this automatically: both the full player and the persistent mini-player POST /complete (with reached_end: true) on the browser ended event, so a title watched to the end in the web app leaves Continue Watching on its own. Native clients (Roku, mobile, Tizen, Windows) do not call this endpoint yet — titles finished on those clients will still linger in Continue Watching and will not finalize watch-time until each client is updated to POST /complete. This is a tracked follow-up.
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). |
GET | /api/v1/admin/users/{id}/bandwidth | Read a user's current-period relay usage + caps (includes throttle_bps). |
PUT | /api/v1/admin/users/{id}/quota | Set a user's monthly byte caps + concurrent-stream cap (0 = unlimited). |
PUT | /api/v1/admin/users/{id}/throttle | Set a user's durable relay bandwidth throttle (throttle_bps; 0 = Unlimited). |
The per-user relay bandwidth endpoints above (
/bandwidth,/quota,/throttle) plus the self endpointGET /api/v1/me/bandwidthare documented in full — bodies, allow-listed throttle levels,0= Unlimited semantics, and the durable-vs-monthly distinction — in Relay Tuning.
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, served from the normalized music_artists / music_albums / music_tracks tables (MusicLibraryService).
There are no MusicBrainz IDs in these payloads
This section previously documented every music entity as being keyed by an mbid, and an artist_mbid / album_mbid filter on the listings. None of that exists. No music response emits an mbid field, and an unknown query parameter is silently ignored — so a client that sends ?artist_mbid=… gets a 200 with unfiltered results, which is indistinguishable from a working filter until you check the data.
- Artists are keyed by their display name, albums by their title, tracks by their
media_itemsUUID. - The route placeholder is literally spelled
{mbid}in the server's route table (Application::loadMusicRoutes()), but the value it receives is the artist name / album title. The placeholder name is a leftover; the identity is not. - The one server-side filter that does exist is
?artist=onGET /api/v1/music/albums(and onGET /api/v1/music/albums/{album_title}for disambiguation).
Verified against phlix-server 96dbb3a4: src/Server/Http/Controllers/MusicController.php:632-647 (formatArtist() — emits no mbid), :667-683 (formatAlbum()), :709-737 (formatTrack()), src/Server/Core/Application.php:1925-1933 (the live route table).
Paging applies to all three listings
/music/artists, /music/albums and /music/tracks are all offset-paged and all return a real total.
| Parameter | Behaviour |
|---|---|
limit | Clamped server-side into [1, 100]. 100 is both the default and the hard ceiling — it is not raisable by a client. A non-numeric or absent value becomes 100; a larger value is silently clamped down to 100. |
offset | Clamped to >= 0. No upper bound. |
total is a real COUNT(*) over the whole matching set, not the page length, so offset + limit < total is the correct "there is another page" test.
The ceiling is PageLimit::MAX (src/Common/Http/PageLimit.php:51), applied by Request::queryPageSize() / queryOffset() (src/Server/Http/Request.php:545-563); the controllers pass PageLimit::MAX as the default at MusicController.php:119-120, :258-259 and :398-399. The ceiling exists because the server is a resident Workerman process — an unclamped limit is a memory-exhaustion vector against a worker that is concurrently serving everyone else.
Auth: every route in this section is registered inside an AuthMiddleware group (Application.php:1923-1934), so all of them require a signed-in user.
GET /api/v1/music/artists
List a page of artists across all music libraries. music_* rows carry no library_id, so there is no library_id parameter on any /music/* route.
Auth: Required (Bearer token)
Query parameters:
limit(optional) — Page size, clamped to[1, 100](default: 100)offset(optional) — Row offset, clamped to>= 0(default: 0)
Response 200:
{
"artists": [
{
"name": "Artist Name",
"image_url": null,
"album_count": 5,
"track_count": 42,
"albums_truncated": false,
"albums": ["Album 1", "Album 2"]
}
],
"total": 2197,
"limit": 100,
"offset": 0
}albums is the artist's embedded album titles, capped at 100 per artist on this listing. album_count is always the true total, so albums_truncated tells you the two disagree rather than leaving a short list to look complete.
GET /api/v1/music/artists/{mbid}
Get one artist with the titles of their albums.
Auth: Required (Bearer token)
Parameters:
{mbid}(path) — The artist's display name, URL-encoded. Despite the placeholder's name this is not a MusicBrainz ID;music_artistshas an AUTO_INCREMENT primary key that is never exposed to clients. Matching is exact (WHERE a.name = ?) and case-insensitive, becausemusic_artistsisCOLLATE=utf8mb4_unicode_ci(migrations/065_music_library.sql:36). The path segment is not trimmed — only the?artist=query filter is.
Response 200:
{
"artist": {
"name": "Artist Name",
"image_url": null,
"album_count": 5,
"track_count": 42,
"albums_truncated": false,
"albums": ["Album 1", "Album 2"]
}
}The detail view raises the embedded-album cap to 2000, so a long discography is not truncated here the way it is in the listing.
Response 400: Empty artist name Response 404: Artist not found
GET /api/v1/music/albums
List a page of albums, optionally restricted to one artist.
Auth: Required (Bearer token)
Query parameters:
artist(optional) — Exact artist name, case-insensitive. The value is trimmed; an absent, empty or whitespace-only value means no filter (never "the artist whose name is the empty string"). This is the only filter on this endpoint.limit(optional) — Page size, clamped to[1, 100](default: 100)offset(optional) — Row offset, clamped to>= 0(default: 0)
Response 200:
{
"albums": [
{
"name": "Album Name",
"artist": "Artist Name",
"year": 2024,
"album_art_url": null,
"track_count": 12,
"tracks_truncated": false,
"tracks": ["… track objects, see below …"]
}
],
"total": 5091,
"limit": 100,
"offset": 0,
"artist": null
}The top-level artist key echoes the filter the server actually applied (null when none was). Read it: it is how you tell a server-side filter from a server that ignored your parameter. total is counted within the same filter, so it describes the set the page came from.
Each album embeds its tracks (the same objects as /music/tracks), capped at 100 per album on this listing; track_count stays the true indexed count and tracks_truncated flags the disagreement.
GET /api/v1/music/albums/{mbid}
Get one album with its full track listing.
Auth: Required (Bearer token)
Parameters:
{mbid}(path) — The album title, URL-encoded. Not a MusicBrainz ID.
Query parameters:
artist(optional) — Exact artist name (case-insensitive, trimmed) disambiguating a shared title.
A title is not an identity
Album titles are not unique — on a real 5,091-album library, 2,622 of them share a title with another album. Without ?artist= the server returns a deterministic first match, which is reproducible but not necessarily the album the user clicked. Pass ?artist= whenever you have it.
Response 200:
{
"album": {
"name": "Album Name",
"artist": "Artist Name",
"year": 2024,
"album_art_url": null,
"track_count": 12,
"tracks_truncated": false,
"tracks": [
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"name": "Track Title",
"artist": "Artist Name",
"album": "Album Name",
"album_artist": "Artist Name",
"year": 2024,
"genre": null,
"track_number": 1,
"disc_number": 1,
"duration_secs": 245,
"composer": null,
"stream_url": "/media/550e8400-.../stream?exp=...&sig=..."
}
]
}
}The detail view raises the embedded-track cap to 2000, so a long compilation is not truncated here.
Response 400: Empty album title Response 404: Album not found
GET /api/v1/music/tracks
List a page of tracks across all music libraries.
Auth: Required (Bearer token)
Query parameters:
limit(optional) — Page size, clamped to[1, 100](default: 100)offset(optional) — Row offset, clamped to>= 0(default: 0)
There is no artist or album filter on this endpoint. To list one album's tracks, read the embedded tracks array from /music/albums/{album_title}.
Response 200:
{
"tracks": [
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"name": "Track Title",
"artist": "Artist Name",
"album": "Album Name",
"album_artist": "Artist Name",
"year": 2024,
"genre": null,
"track_number": 1,
"disc_number": 1,
"duration_secs": 245,
"composer": null,
"stream_url": "/media/550e8400-.../stream?exp=...&sig=..."
}
],
"limit": 100,
"offset": 0,
"total": 29245
}The track object (identical everywhere a track appears — listing, track detail, and embedded inside an album):
| Field | Notes |
|---|---|
id | The track's media_items UUID — the same id GET /media/{id}/stream accepts. null only if the row has no media_item_id. |
name | Track title. Note: name, not title. |
artist | Artist display name, or null. |
album | Album title, or null. |
album_artist | Mirrors artist — music_tracks.artist_id is the scanner's denormalization of the album's artist, so there is no separate value to report. |
year | The album's year, or null. |
genre | Always null. There is no genre column in the music schema; the key is kept only so the response shape does not change. |
track_number, disc_number | Integers, or null when unknown (distinguished from 0). |
duration_secs | Integer seconds, or null. |
composer | Always null, same reason as genre. |
stream_url | A freshly minted signed /media/{id}/stream URL. This is the only locator a client needs. |
There is deliberately no path key — it used to expose the server's absolute filesystem layout, and this payload is reachable over the internet-facing hub relay. There is also no bitrate / sample_rate / channels / nested metadata object.
GET /api/v1/music/tracks/{id}
Get a single track.
Auth: Required (Bearer token)
Parameters:
{id}(path) — The track'smedia_itemsUUID (matched againstmusic_tracks.media_item_id, which is UNIQUE).
Response 200: { "track": { … } } — the same track object documented above.
Response 400: Empty track id Response 404: Track not found
GET /api/v1/music/now-playing
Get the current music playback state for the signed-in user.
Auth: Required (Bearer token)
This endpoint always answers {"now_playing": null} today
The handler reads current_media_id, position_ticks and playback_state off a row from the sessions table (MusicController::nowPlaying() at MusicController.php:487-501, via SessionManager::getUserSessions() → SELECT * FROM sessions). The sessions table has none of those three columns — it is id, user_id, profile_id, device_id, device_name, device_type, last_activity, created_at (migrations/001_initial_schema.sql:73-84, plus profile_id from migrations/002_user_profiles_and_parental_controls.sql:56; no later migration alters it). Live playback position lives in the separate playback_state table, which this handler never reads.
So the current_media_id === null early return at MusicController.php:497-501 fires unconditionally and no other branch is reachable. Do not build against a non-null response from this route. Use the session/playback endpoints instead.
Response 200:
{
"now_playing": null
}For completeness, the shape the handler would emit if sessions carried those columns is {"now_playing": {"track": { …track object… }, "position": <int>, "state": "playing"|"paused"|"stopped", "session_id": "…"}} — note track (a full track object), not track_id, and position, not position_secs.
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