Revue Server Protocol
Version 4 · Status: stable
Revue is a live TV player for Apple TV. Besides M3U playlists, it can show live rooms from your own server. This document describes the HTTP interface such a server provides. If you build a server that follows it, Revue lists its rooms, shows which ones are live, plays them, and turns on optional features such as live comments.
Revue does not relay your streams and has no account system. The server address is stored only on the Apple TV, and Revue connects to it directly.
For AI agents: this page is the complete specification. Implement the required parts in §2–§5, then go through the checklist in §9 before you finish.
1. Overview
A Revue server is a normal HTTP server on the user’s local network. One server can offer several sources. Each source is a list of sections, and each section is a list of rooms. A room is one live stream.
Revue ──► GET /api/sources which sources does this server offer?
──► GET {source.url}/api/sections rooms of one source, grouped into sections
──► GET {room.streamUrl} play: an HLS master playlist, or 410 when not live
──► GET {source.url}/api/rooms/{id} refresh one saved room
──► {room.actions.*} optional capabilities, declared per room
Design rules:
- The client renders what the server declares. Revue knows nothing about where your streams come from. Which rooms exist, which are live, which features they support, and how their status is labeled are all decided by the server.
- The server provides every URL. Apart from the two paths in §4.2 and §4.4, Revue only uses URLs it received from the server. It never builds a URL from an identifier.
- A URL in
actionsturns a feature on. If the URL is absent, the feature is off and Revue hides it.
2. Discovery
Revue finds a server in one of two ways. Both lead to the same server root URL, http://<host>:<port>.
Manual entry. The user types the root URL in Revue’s settings. Both http:// and https:// work (lowercase); without a scheme, Revue assumes http://.
Bonjour (mDNS). Advertise exactly one record per server process:
| Item | Value |
|---|---|
| Service type | _livehub._tcp (a fixed technical constant; it cannot change) |
| Instance name | Shown to the user. Keep it the same across restarts: Revue uses it to find a saved server again after its IP address changes |
TXT ver |
"4". Revue does not use a record with any other value, and marks it as incompatible in its settings |
| Host / port | Your server. Revue resolves an IPv4 address |
The number of sources does not change the number of records. Sources are listed by /api/sources.
3. Conventions
- JSON. Responses are UTF-8 JSON unless stated otherwise.
- Absolute URLs. Every URL in a response is absolute (
http://192.168.1.20:8800/...). Build them from theHostheader of the request, so they work with whatever address the client used. A fixed address that you configure is fine too, as long as Revue can reach it. - No caching. Every JSON endpoint in this document responds with
Cache-Control: no-store. Revue requests the room list with default HTTP caching, so without this header it can keep showing an old list. - Unknown fields are ignored. You may add your own fields to any object. Revue ignores fields it does not know, with the reserved names below.
- Reserved names. Do not send a room field named
flvUrl, and do not use paths under/api/cast/. Revue uses them for features outside this document. Answer any path you do not implement with404. - Exact types. Revue decodes each response as a whole. One field with the wrong type in one room, such as
viewerssent as"1280"or1280.0, makes the whole list fail to load. Integers must be JSON integers. - Errors. A successful response has
"ok": true. A failed request returns a non-2xx status, with the body{"ok": false, "error": "<human-readable text>"}. The410in §4.5 is the one exception and has its own body. Do not send"ok": falsewith status200. You may adderrorCode(a string) for diagnostics. Revue does not showerrorto the user and does not branch onerrororerrorCode, except for"errorCode": "NOT_LIVE"on a410(§4.5). - Status codes carry meaning. Use them precisely:
| Situation | Status |
|---|---|
| Success | 200 |
| The room exists but is not live (stream endpoints only, §4.5) | 410 |
| The thing is confirmed not to exist | 404 |
| The upstream failed, timed out, or you cannot tell right now | 5xx (usually 502 or 503) |
“Cannot tell” is never “not found” and never “not live”. Revue keeps its current state on 5xx and tries again later. A wrong 404 marks a saved room as unavailable; a wrong 410 stops playback.
-
Request headers. Revue sends two standard headers on its requests to your server:
Accept-Language: the app’s current interface language, as one tag such asen,zh-Hans, orzh-Hant. It changes as soon as the user changes the language in Revue. You may use it to localizestatusLabel.User-Agent:Revue/<version> (<build>; tvOS <system version>), for exampleRevue/1.1.2 (607; tvOS 27.0). Use it for logs only. Do not change behavior based on it. Compatibility is decided byapiVersion(§7).
The system video player requests
streamUrl, its playlists, and its segments with its own headers. Before Revue shows the offline page, it requestsstreamUrlitself with the headers above, so a localizedstatusLabelin the410body reaches the user.
4. Endpoints
4.1 GET /api/sources
Relative to the server root. Lists the sources of this server. This is the first request Revue makes.
{
"apiVersion": 4,
"ok": true,
"sources": [
{ "id": "outdoors", "name": "Outdoors", "url": "http://192.168.1.20:8800/outdoors" }
]
}
| Field | Type | Required | Meaning |
|---|---|---|---|
apiVersion |
integer, always 4 |
yes | With any other value, Revue cannot use the server and shows that it received no source list |
ok |
boolean | yes | |
sources |
array, at least one item | yes | Display order |
sources[].id |
string | yes | Stable identifier. Revue uses it to remember the selected source and to store saved rooms, so never change it |
sources[].name |
string | yes | Tab label |
sources[].url |
string (URL) | yes | Base URL of the source, absolute, no trailing slash. Revue treats it as opaque and resolves the source endpoints against it |
Keep this endpoint cheap. Revue calls it on launch and whenever the server address changes.
4.2 GET {source.url}/api/sections
The room list of one source. Revue appends this path to source.url. Both query parameters are optional:
refresh=1asks you to bypass your own cache. Revue sends it when the user refreshes by hand.maxAge=<seconds>says how old the data may be (age = now −generatedAt). Supporting it is optional. If your data is fresh enough, answer at once. If not, start a refresh, or join the one already running, and answer when it finishes. Do not hold the request longer than about 10 seconds: after that, answer with the data you have and"refreshing": true, and Revue asks again right away. If you ignoremaxAge, Revue re-fetches on its own schedule instead.
{
"apiVersion": 4,
"ok": true,
"generatedAt": "2026-10-24T12:00:00.000Z",
"sections": [
{
"id": "featured",
"title": "Featured",
"rooms": [
{
"id": "sunrise",
"slug": "1024",
"name": "Savanna sunrise flight",
"group": "Kito Mwangi",
"live": true,
"viewers": 1280,
"coverImageUrl": "http://192.168.1.20:8800/covers/sunrise.jpg?v=7",
"streamUrl": "http://192.168.1.20:8800/outdoors/hls/sunrise.m3u8",
"actions": {
"statusUrl": "http://192.168.1.20:8800/outdoors/api/rooms/sunrise/status"
}
}
]
}
]
}
| Field | Type | Required | Meaning |
|---|---|---|---|
apiVersion |
integer, always 4 |
yes | |
ok |
boolean | yes | |
generatedAt |
string | yes | When this data was actually produced, in UTC: 2026-10-24T12:00:00Z or 2026-10-24T12:00:00.000Z. When you serve cached data after a failed refresh, keep the time of the last success |
stale |
boolean | no (default false) |
true when this is cached data because the latest refresh failed. Revue tells the user when they refresh by hand |
refreshing |
boolean | no (default false) |
Only for requests with maxAge: true when your data is older than asked and your refresh is still running. Revue asks again at once |
playbackHints |
object | no | See §6 |
sections |
array | yes | The final display list. Filter and sort on the server. Do not send empty sections (Revue hides them) |
sections[].id |
string | yes | Unique within the source and stable across refreshes |
sections[].title |
string | yes | Section heading |
sections[].rooms |
array of rooms | yes | See §4.3 |
4.3 Room object
| Field | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | The room’s identity. See “Identity” below |
name |
string | yes | Room title |
slug |
string | no | A short human-readable handle, such as a channel number or a username. Display only: Revue shows it next to the title in the player when it differs from name |
group |
string | no | Secondary line on the room card, such as the host’s name. Revue shows slug when absent |
live |
boolean | yes | true when streamUrl would play live video right now. Revue uses it for the LIVE badge. It is a hint: the authoritative “not live” signal is a 410 from streamUrl (§4.5) |
status |
string | no | Opaque value for your own debugging. Revue never branches on it. The 410 body in §4.5 is the one exception |
statusLabel |
string | no | Human-readable status, such as “Replay”. Shown on the offline page and for saved rooms. Localize it on the server |
viewers |
integer ≥ 0 | no | Viewer count. Omit it when you do not know; do not send 0 for “unknown” |
isFollowing |
boolean | no (default false) |
Only meaningful with the follow capability (§5.2) |
coverImageUrl |
string (URL) | no | Cover image. Revue shows a plain placeholder when absent. See “Cover images” below |
streamUrl |
string (URL) | yes | The stream endpoint, §4.5 |
actions |
object | yes (may be {}) |
Optional capabilities, §5 |
Identity. id identifies the room, not its position in a list:
- It is unique within the source, never changes for the same room, and contains no
/. - When the same room appears in several sections, it has the same
idin each of them. - Revue uses it to recognize the room across refreshes, to remove duplicates when the user switches rooms with the remote, to store saved rooms, and to build the path in §4.4. Saved rooms are stored by source
idplus roomid, so changing either one loses the user’s saved rooms. slugis never used for identity. You may change it at any time.
Live status. live must be true to the source. Do not mark a room offline only because it dropped out of a list you fetched; look it up. If you cannot find out, keep the last known value or fail with 5xx.
Cover images. Revue caches each image by URL for as long as the app runs, and does not retry a URL that failed once. When the picture changes, change the URL, for example with a version query such as ?v=7.
4.4 GET {source.url}/api/rooms/{id}
One room, looked up by its id. Revue percent-encodes id as one path segment. Revue calls this endpoint to refresh each saved room when the user opens the saved rooms page. Only a room that was refreshed successfully can be played from the saved list, so this endpoint is required.
{
"apiVersion": 4,
"ok": true,
"generatedAt": "2026-10-24T12:00:00.000Z",
"room": { "id": "sunrise", "name": "Savanna sunrise flight", "live": false, "streamUrl": "...", "actions": {} }
}
roomhas the same shape as a room in §4.3, includingstreamUrlandactions.- Return the room even when it is not live (
"live": false). Do not filter it out. - Return
404only when the room is confirmed not to exist. A saved room that is still live must not disappear because it is not in your current list: look it up at its origin. - Upstream failure is
5xx. Revue then keeps showing its last snapshot. - Methods other than
GETreturn405.
4.5 GET {room.streamUrl}
-
Live:
200with an HLS master playlist (application/vnd.apple.mpegurl). Anything AVPlayer can play works, including HEVC and HDR. -
Not live:
410 GonewithCache-Control: no-storeand this body:{ "ok": false, "errorCode": "NOT_LIVE", "statusLabel": "Offline", "status": "offline" }In this body,
statushas defined values, unlike the room object’sstatus(§4.3). Revue uses it only to pick the icon on the offline page:statusMeaning offlineThe broadcaster is not live privateThe room is live, but the broadcast is not public (for example, members only) hiddenThe broadcaster has turned the video off for now awayThe broadcaster has stepped away for now. The room is still open replayThe broadcaster is not live, and the room is showing a recording unavailableAny other reason, or you cannot tell which Revue treats a value it does not know as
unavailable. New values may be added later. Existing values keep their meaning."errorCode": "NOT_LIVE"is required: a410without it is treated as a playback failure. With it, Revue shows an offline page instead of an error. Send410only when you have confirmed the room is not live. If a child playlist is requested after the stream ends, answer it with410in the same way. -
Confirmed not to exist:
404. Cannot tell right now:5xx.
You may answer a live streamUrl with a 302 redirect to an HLS master playlist on another host. The 410 must still come from your server.
Repeated requests. Revue requests streamUrl more than once, and not only from the player: before playing a room marked not live, to read the stream’s format, while recovering from a stall, and on the pages below. Every GET must work and must not end the session that an earlier request started. Answer within a few seconds: these checks time out after 8 seconds and then count as “cannot tell”.
Offline and error pages. While the offline page is shown, Revue requests streamUrl every statusPollSeconds (§6): a new 410 updates the page, and a 200 resumes playback. If a room drops back to the offline page within 60 seconds of resuming, Revue waits until those 60 seconds have passed before resuming again. While a playback error is shown, Revue also requests streamUrl every statusPollSeconds: a 410 switches to the offline page, and a 200 retries playback once the error has been shown for 60 seconds.
Revue never adds query parameters to streamUrl. Put everything you need into the URL itself.
5. Capabilities (actions)
Every member is optional and holds an absolute URL. A present URL turns the capability on. Pairs must be complete; a half pair is treated as off.
| Member | Method | Capability |
|---|---|---|
statusUrl |
GET | Room status polling, §5.1 |
followUrl + unfollowUrl |
POST | Follow and unfollow, §5.2 |
streamOptionsUrl + switchStreamUrl |
GET / POST | Pick a line or a quality, §5.3 |
switchEdgeUrl |
GET | Ask the server to pick another upstream, §5.4 |
danmakuUrl |
GET | Live comments, §5.5 |
5.1 GET {statusUrl}
Revue polls it every statusPollSeconds while a room plays.
{ "ok": true, "live": true, "statusLabel": "Live", "bitrateKbps": 6200 }
| Field | Type | Required | Meaning |
|---|---|---|---|
ok |
boolean | yes | |
live |
boolean | yes (when ok) |
Same meaning as the room’s live. A response without it is ignored as a whole |
statusLabel |
string | no | Current status text |
bitrateKbps |
integer | no | The recent total bitrate (audio + video) of this room as seen by your server. Revue shows it in the player. Omit it when you do not know |
- Unknown room:
404. Upstream failure:5xx. Revue keeps its state and keeps polling in both cases; it never reloads playback because of a failed poll. livemust be real, looked up the same way as in §4.4.- Keep it cheap: every viewer calls it every few seconds.
5.2 Follow
POST {followUrl} and POST {unfollowUrl} have no body. Respond with:
{ "ok": true, "following": true }
following is the state after the operation. Any non-2xx status is a failure.
5.3 Lines and qualities
For rooms that come in several variants, such as different qualities, or the same content from different origins (“lines”).
GET {streamOptionsUrl}:
{
"ok": true,
"lines": [ { "id": "a", "label": "Line A" }, { "id": "b", "label": "Line B" } ],
"qualities": [ { "id": "1080", "label": "1080p" }, { "id": "720", "label": "720p" } ],
"currentLineId": "a",
"currentQualityId": "1080"
}
linesandqualitiesare required arrays. Send[]when you have none to offer.- A line may also have
provider(string). Revue groups lines with the sameproviderunder that heading. currentLineIdandcurrentQualityIdare optional. Omit them when you cannot tell; do not send an empty string.- Ids are opaque strings. Revue only shows and returns them.
POST {switchStreamUrl} with a JSON body {"lineId": "b"} or {"qualityId": "720"}. Accept both together as well. Respond 200 {"ok": true}. An unknown id or a wrong body is 400 {"ok": false, "error": "...", "errorCode": "INVALID_INPUT"}.
Remember the choice per room on the server, and apply it to streamUrl. After a successful switch, Revue reloads streamUrl.
5.4 GET {switchEdgeUrl}
Asks the server to pick a different upstream for this room. Revue calls it during automatic recovery (at most twice while the same room keeps playing), when the user retries from the error page, and when the user refreshes a room without §5.3. Revue ignores the response and then reloads streamUrl. When absent, Revue simply reloads. If the user picked a quality with §5.3, keep it.
5.5 Live comments: GET {danmakuUrl}
A long-lived response with Content-Type: application/x-ndjson, sent with chunked transfer encoding and Cache-Control: no-store. Each line is one JSON object followed by \n. Keep the connection open until the client disconnects.
type |
Fields | Meaning |
|---|---|---|
chat |
text (required), color (optional, 24-bit RGB integer such as 16777215; white when absent), nick (optional, not displayed), ts (optional, milliseconds) |
One comment. Revue displays text |
hb |
none | Heartbeat. Send one immediately after the connection opens, then every 15 seconds |
error |
reason |
Why the server refuses or ends the stream, such as busy or shutdown. For your logs; Revue does not display it |
{"type":"hb"}
{"type":"chat","text":"hello","color":16777215,"ts":1790000000000}
{"type":"hb"}
- Revue ignores unknown
typevalues. - Revue reconnects when the stream ends, fails, or sends nothing for 45 seconds. It backs off from 1 second up to 30 seconds between attempts. Any line received resets the back-off to 1 second, so do not accept a connection and then close it right away: Revue would reconnect about every second.
- There is no replay: comments sent while disconnected are lost.
- A room that is not live may send only heartbeats. That is not an error.
- You may drop or shorten comments to protect your server. Revue does not treat gaps as errors.
- To refuse (for example, too many connections), respond
503with a single line{"type":"error","reason":"busy"}.
Comments come only from your server. Revue shows them only when the user turns them on.
6. Playback hints
playbackHints in §4.2 tunes playback for the rooms of that source. All fields are optional and in seconds. Revue clamps every value to the range shown.
| Field | Default | Range | Meaning |
|---|---|---|---|
statusPollSeconds |
10 | 5–60 | Interval of statusUrl polling, and of streamUrl checks on the offline and error pages |
startupTimeoutSeconds |
15 | 5–60 | Recover if playback has not started in this time |
stallRecoverySeconds |
25 | 10–120 | Recover if playback has made no progress in this time |
liveHoldBackSeconds |
8 | 4–30 | When playback falls more than 30 seconds behind the live edge, Revue jumps forward to this distance behind it. The starting position comes from the HLS playlist, as in any AVPlayer stream |
Send hints only when your streams need them. The defaults suit most HLS streams.
7. Versioning
- Two checks: the Bonjour TXT
vermust be"4", and everyapiVersionmust be4. - New optional fields and new optional endpoints do not change the version. Revue ignores what it does not know.
- Changing a required field or the meaning of an existing field changes the version.
8. JSON Schemas
Machine-readable response shapes (JSON Schema, draft 2020-12):
https://revue.vastlogic.dev/docs/server-protocol/schema/v4/sources-response.schema.jsonhttps://revue.vastlogic.dev/docs/server-protocol/schema/v4/sections-response.schema.jsonhttps://revue.vastlogic.dev/docs/server-protocol/schema/v4/room-response.schema.jsonhttps://revue.vastlogic.dev/docs/server-protocol/schema/v4/room-status-response.schema.jsonhttps://revue.vastlogic.dev/docs/server-protocol/schema/v4/follow-response.schema.jsonhttps://revue.vastlogic.dev/docs/server-protocol/schema/v4/stream-options-response.schema.jsonhttps://revue.vastlogic.dev/docs/server-protocol/schema/v4/danmaku-message.schema.json
The schemas cannot check uniqueness, stable identity, or the meaning of status codes. The checklist below covers those.
9. Implementation checklist
Most broken servers run fine but behave wrongly. Check each item before you call the server done.
Discovery and basics
-
GET /api/sourcesworks at the server root and returns at least one source. - Bonjour advertises
_livehub._tcpwith TXTver=4, one record per process, with an instance name that stays the same across restarts (or the user is told to enter the address manually). - Every URL in every response is absolute and reachable from the Apple TV. No
localhostor127.0.0.1in responses. - Every JSON endpoint sends
Cache-Control: no-store. -
apiVersionis the integer4in sources, sections and room responses. - Paths you do not implement return
404. No room field namedflvUrl, no paths under/api/cast/. - Every field has exactly the type in this document. Integers are JSON integers.
Lists and rooms
-
generatedAtis when the data was produced, not when the response was sent. It moves forward when you really refresh, and stays put when you serve cached data. (Revue re-fetches a list that looks old; a frozengeneratedAtwithoutstalemakes it re-fetch for about two minutes.) -
generatedAtis in UTC and ends withZ, optionally with milliseconds. - No empty sections. Section
ids are unique within the source. - Room
idis unique within the source, contains no/, never changes, and is the same in every section the room appears in. -
actionsis present on every room, even if it is{}. -
viewersis omitted, not0, when you do not know it. - A cover image that changes gets a new URL.
-
GET {source.url}/api/rooms/{id}is implemented, returns offline rooms with"live": false, and finds rooms that are not in the current list.
Live status and playback
-
livecomes from a real lookup. A room that dropped out of a list is not offline until you have checked. -
streamUrlreturns410with"errorCode": "NOT_LIVE"in the body, and only when the room is confirmed not live. -
streamUrlcan be requested again at any time without breaking playback that is already running. -
streamUrlanswers within a few seconds, whether it serves the playlist or redirects. -
404only when the room is confirmed not to exist. - Every upstream failure or timeout is
5xx, never404or410.
Capabilities
-
followUrlandunfollowUrlappear together or not at all. Same forstreamOptionsUrlandswitchStreamUrl. -
currentLineIdandcurrentQualityIdare omitted, not empty, when unknown. - The comment stream sends a heartbeat immediately and then every 15 seconds, one JSON object per line, and does not close a connection right after accepting it.
-
statusUrlis cheap enough to be called every few seconds by every viewer.
Before you finish
- Validate each response against the schemas in §8.
- Add the server in Revue, play a live room, and open an offline room to see the offline page.