Developers
Client API
The HTTP API players use to sign in, browse, play and report progress.
Every Wren TV server has a JSON API on the same port as its web app (32500). The web player, the apps and the server's
own web app all use it. This page is a summary. The API may still change during early access.
Basics
- Base URL: any of the server's addresses, such as
http://192.168.1.20:32500or its wrentv.net address. - Bodies are JSON, both ways.
- Errors come back as
{"error": "message"}with a 4xx or 5xx status. - CORS is open (
Access-Control-Allow-Origin: *) on every route, including streams, so browser apps on any origin can use it.
Server info
GET /api/server needs no sign-in. It tells a client what kind of server it's talking to:
json{
"name": "Living Room",
"machineId": "3f9c…",
"version": "0.1.0",
"authMode": "supabase",
"setupRequired": false,
"streaming": true,
"localUrls": ["https://192-168-1-20.ab12cd.wrentv.net:32500"],
"supabase": { "url": "https://….supabase.co", "publishableKey": "sb_publishable_…" },
"serverId": "…"
}
GET /api/health returns {"status": "ok"}.
Authentication
Send a bearer token on every other request:
httpAuthorization: Bearer <token>
Wren TV accounts (
authMode: "supabase"): the token is the user's Supabase access token, from signing in to Wren TV accounts with email and password. The server checks it, and asks Wren TV accounts whether the user may use this server.Local accounts (
authMode: "local"): sign in to the server itself.bash
curl -X POST http://localhost:32500/api/auth/login \ -d '{"username":"me","password":"…","device":"My App"}' # → {"token": "…", "user": {…}}
GET /api/me returns the signed-in user, their role on this server, the library IDs they can see, and their limits:
{"canStream", "canDownload", "maxStreams", "maxDownloads"} (0 means no limit).
Browsing
| Method | Path | Returns |
|---|---|---|
| GET | /api/home | Continue watching, recently added per library, Home collections and more |
| GET | /api/libraries | Libraries the user can see |
| GET | /api/libraries/{id}/items | A library's movies or shows. Query: sort (title, added, year, rating), genre, q, unwatched=1, limit (default 500), offset |
| GET | /api/items/{id} | One item, with its files and the user's progress |
| GET | /api/items/{id}/children | A show's seasons, or a season's episodes |
| GET | /api/items/{id}/extras | Cast and related titles |
| GET | /api/search?q= | Movies, shows and episodes matching a title (up to 60) |
| GET | /api/genres, /api/genres/items?genre= | Genres, and the titles in one |
| GET | /api/collections, /api/collections/{id} | Collections, and one collection's titles |
| GET | /api/discover/trending | Trending titles on TMDB |
| GET | /api/images/{size}/{file} | Cached artwork. Needs no token |
Lists only ever include libraries the user has access to.
Playback
Start playing
POST /api/items/{id}/play with what the client can decode:
json{
"caps": {
"engines": ["avplayer"],
"nativeVideo": ["h264", "hevc"],
"nativeAudio": ["aac", "ac3", "eac3", "flac"],
"nativeContainers": ["mp4", "mov"],
"dolbyVision": false,
"hdr": true,
"maxKbps": 0,
"audioFallback": "flac"
},
"fileId": null,
"audioIndex": null,
"maxWidth": 0
}
| Field | Means |
|---|---|
engines | The client's players, in preference order: avplayer (a native, AVPlayer-class engine) and/or mpv (libmpv) |
nativeVideo, nativeAudio, nativeContainers | What the native engine decodes |
dolbyVision, hdr | Whether the screen shows them |
maxKbps | Bitrate limit; 0 for none |
audioFallback | What unsupported audio becomes during a remux: alac, flac or aac |
forceEngine, forceTranscode | For testing |
fileId | A specific version; omit to let the server choose |
audioIndex | An audio track; omit to use the user's language preference |
maxWidth | Scale transcodes down to this width |
The response says how to play it:
json{
"status": "ready",
"sessionId": "…",
"engine": "avplayer",
"method": "remux",
"url": "/stream/<session>/main.m3u8",
"decision": { "videoCopy": true, "audioCopy": true, "audioCodec": "eac3", "reasons": ["remux mkv to fMP4 (no re-encode)"] },
"duration": 7342.1,
"resumePosition": 1810,
"subtitles": [{ "index": 0, "language": "en", "codec": "subrip", "forced": false, "url": "…" }],
"subtitleIndex": -1,
"chapters": [],
"markers": { "intro": { "start": 62.5, "end": 151.0 }, "source": "fingerprint" },
"versions": [{ "id": "…", "label": "4K · Dolby Vision · 58.2 GB", "height": 2160 }]
}
methodisdirect(the raw file, with range requests),remuxortranscode(both HLS with fMP4 segments).urlis relative to the server:/stream/{session}/main.m3u8for HLS, or/stream/{session}/file.<ext>for direct play. Stream URLs need no token: the session ID is the credential, because players can't always send headers. They stop working when the session ends.markershasintroandcreditsranges when known, andsourcesays where they came from (chaptersorfingerprint).subtitles[].urlgives WebVTT for text subtitles. While a track is still being converted, the response hasX-Subtitle-Complete: false; fetch it again until it'strue.202 {"status": "pending"}means a streamed title is still finding a stream. Show themessageand retry every few seconds.424means no cached release was found for a streamed title, and503means the server is at its stream limit. Both have anerrormessage to show.429means the user is at their own limit of streams at once.
Report progress and stop
| Method | Path | Body |
|---|---|---|
| POST | /api/items/{id}/progress | {"position": 1830.5, "duration": 7342.1} in seconds. Send it every few seconds while playing |
| POST | /api/items/{id}/watched | {"watched": true}. On a show or season, marks every episode |
| DELETE | /api/sessions/{sessionId} | Ends the playback session, stopping any remux or transcode |
Preferences
GET /api/me/prefs and PATCH /api/me/prefs read and change the user's playback languages:
{"audioLanguage": "en", "subtitleLanguage": ""}, as ISO 639-1 codes. An empty subtitleLanguage means off.
Streaming
When streaming is true in /api/server and the user's limits.canStream:
| Method | Path | Body or returns |
|---|---|---|
| GET | /api/discover/search?q= | {"streaming": true, "items": [ {"tmdbId", "mediaType", "title", "year", "posterPath", "item"} ]}. item is set when the server already has the title |
| POST | /api/streaming/titles | {"tmdbId": 693134, "mediaType": "movie"} (movie or show). Adds it to the streaming library and returns the item |
The returned item plays like any other; the first play may answer 202 pending while a stream is found.
Downloads
POST /api/items/{id}/download with {"fileId": "…"} (optional; the best version otherwise) returns a link to the
original file:
json{ "status": "ready", "url": "/download/<ticket>/Dune - Part Two (2024).mkv", "fileName": "…", "fileId": "…",
"size": 61234567890, "label": "4K · Dolby Vision", "expiresAt": 1760086400 }
The URL needs no token (the ticket is the credential), supports range requests for resuming, and works for 24 hours.
It answers 429 while the user has maxDownloads downloads running. For a streamed title with no stream yet, the
response is 202 {"status": "pending"}: retry. canDownload on GET /api/items/{id} says whether to show the button.
Live updates
GET /api/events is a server-sent events stream. It sends library.updated, item.added, item.updated,
scan.started and scan.finished events, so clients can refresh without polling.
Admin
Owners and admins also have endpoints to manage libraries (POST/PATCH/DELETE /api/libraries,
POST /api/libraries/{id}/scan, POST /api/items/{id}/match), users and their limits (/api/users), collections,
settings (/api/admin/settings), status (/api/admin/status), stats (/api/admin/stats, /api/admin/history), and
streaming: server downloads (/api/admin/downloads, POST /api/items/{id}/server-download), saved streams
(GET and DELETE /api/items/{id}/streams) and key checks (/api/admin/streaming/check-debrid,
/api/admin/streaming/check-addon). These are what the Manage pages use.
For services that manage libraries without a user, use the integration API instead.