Progress & state
This continues Building a player and Streaming a player once a stream is open. It covers reporting progress, reading watch state, telemetry, offline sync, and the push-event channel.
1. Report progress
Section titled “1. Report progress”Starting playback (POST /api/v1/play-sessions, see
Streaming a player §1)
creates the play session, so there is nothing separate to open. The session is the
authoritative record of resume position. The start response names it in
reporting:
"reporting": { "kind": "progress", "method": "PUT", "playbackSessionId": "uuid", "href": "/api/v1/play-sessions/{id}", "intervalMs": 10000, "unit": "ms" }playbackSessionId is the one id for this play. It is also the PlaySessionId on
the HLS URL, so the stream and the progress reports always belong together.
Reporting uses your normal bearer token, never the grant.
Heartbeat every reporting.intervalMs (about 10 seconds), and on pause, resume and
seek, with a PUT to reporting.href:
curl -X PUT https://lumastra.example.com/api/v1/play-sessions/{playbackSessionId} \ -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \ -d '{ "positionMs": 2760000, "status": "ACTIVE", "eventSeq": 7, "sessionElapsedMs": 120000, "bufferHealthMs": 12000 }'positionMsis the playback position in milliseconds on the selected delivery clock: whole-file time for combined episodes and whole-book time for audiobooks. Never reset it at a file boundary within a book.sessionElapsedMsis required: cumulative milliseconds actually played since the session started, excluding pauses, seeks and buffering.eventSeqis required: a per-session, monotonically increasing integer starting at- A stale sequence number is acknowledged and ignored, so retries are safe.
status:ACTIVE|PAUSED|COMPLETED.COMPLETEDends the session (204). A terminal outcome can instead return200; inspect its body. AddendReason:completion|skip|user|unload|error|switch.- Optional fields include
audioStreamIndex,subtitleStreamIndex,bitrateBps,resolution,playMethod,audioLanguage,subtitleLanguage,subtitleMethod,bufferHealthMsandadaptive.
A heartbeat answers { resumeAnchorAt, progressSaved }, and may also carry action,
reason, superseded, sessionLost or transcode. resumeAnchorAt is the server-clock
timestamp (ISO 8601, or null) to advance your resume cache with. superseded: true
means another device’s session now owns this item’s watch state: playback continues, but
progress no longer persists, so surface a one-time notice. action: "kill" means the
server terminated this session, so stop playback. Ignore transcode if you do not show
progress. sessionLost: true means the session can no longer accept this report; stop
using that reporting ID and create a fresh session for a new playback attempt.
progressSaved: false also occurs before the engagement gate opens: moving the
playhead alone does not prove the viewer watched. Keep reporting honest cumulative
sessionElapsedMs; do not fabricate it from the resume position or seek distance.
Use resumeAnchorAt only when non-null, and never replace a newer cached anchor with
an older acknowledgement.
Ending a session
Section titled “Ending a session”Send a PUT with status: "COMPLETED" and an endReason for a normal end (204).
Use DELETE /api/v1/play-sessions/{id} to terminate a session at once and free its
stream slot.
On page or app teardown, where you cannot issue a PUT, use
POST /api/v1/play-sessions/{id}/beacon. It takes the same body and works with
navigator.sendBeacon when a browser session cookie authenticates the request.
A bearer-only client must use an authenticated POST (for example fetch with
keepalive: true in a browser); sendBeacon cannot set an Authorization header.
Serialize reports per session so a delayed older request cannot overtake the final one.
Each play session has its own SSE channel (GET /api/v1/play-sessions/{id}/events,
owner only) for kill, session_terminated, admin_message, and maintenance frames.
It’s separate from the global stream in §7.
2. Watch state
Section titled “2. Watch state”GET/PATCH /api/v1/watch-state/{id} is the unified per-user watch state node:
watched/unwatched, resume position and favorites. Ratings use the separate
/api/v1/media/{id}/rating resource.
curl https://lumastra.example.com/api/v1/watch-state/550e8400-… -H "Authorization: Bearer <token>"# → { "watched": false, "positionMs": 2760000, "resumeAnchor": "…", "favorite": false, "rating": null }
curl -X PATCH .../api/v1/watch-state/550e8400-… -d '{"watched": true, "favorite": true}'DELETE /api/v1/continue-watching/{itemId} clears an item from Continue
Watching.
3. Telemetry & incidents
Section titled “3. Telemetry & incidents”Quality telemetry (POST /api/v1/playback/telemetry) reports client-side
quality-of-experience metrics for the admin dashboard only; nothing here
affects resume position. Include the playbackSessionId. Batch events
(bandwidth_estimate, rebuffer_event, quality_switch, delivery_window, …) and send
them every 10–30 s, max 100 per request. Account authentication is required on unload
too; use a cookie-authenticated beacon or a bearer-authenticated POST.
Active sessions: GET /api/v1/play-sessions?scope=mine|all lists currently
active play sessions, useful for “already playing on another device” UX. scope=all
requires an administrator.
Incidents are sent as playback_incident events in batches to
POST /api/v1/diagnostics/events; each event can carry a short lead-up
timeline[] so an admin can reconstruct what happened. Key fields include
attemptId (stable across a reconnect, unlike playbackSessionId, which
rotates), triggerType (error/reconnect/terminal) and stage
(manifest/init-segment/media-segment/unknown). Resolve an attempt with
PATCH /api/v1/diagnostics/events/resolve and { "attemptId": "…" }.
These endpoints share a limit of 60 requests per minute per user; see
Client diagnostics for the batch contract.
4. Offline playback sync
Section titled “4. Offline playback sync”Not to be confused with Offline downloads, which moves bytes. This is the opposite direction: uploading playback that already happened on a device with no network, once it reconnects.
curl -X POST https://lumastra.example.com/api/v1/offline-playback-sessions \ -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \ -d '{ "sessions": [{ "eventId": "1f0c1a2e-…", "mediaId": "550e8400-…", "knownAsOf": "2026-08-15T21:04:00Z", "startedAt": "2026-08-17T08:00:00Z", "endedAt": "2026-08-17T08:47:13Z", "positionMs": 1423000, "runtimeMs": 5400000, "sessionElapsedMs": 1187000, "endReason": "stopped" }] }'// 200 — always 200 unless the ENVELOPE itself is malformed{ "results": [{ "eventId": "1f0c1a2e-…", "status": "accepted", "retryable": false }], "summary": { "accepted": 1, "duplicate": 0, "rejected": 0 } }Uses the same media:write scope as POST /play-sessions. No separate opt-in
needed.
eventId: a UUID you generate once and keep locally. It’s the permanent idempotency key. Drop a queued record only on a terminal result.retryable: present on every result.truemeans keep it queued;falsemeans stop (terminal success or a permanently bad record). Treat this flag as authoritative, not thereasonstring.endedAtis the authoritative occurrence time: the moment playback actually stopped on the device, not the moment you came back online.knownAsOf: the moment your client last read server state for that item. It bounds how stale your reported position may be. The server won’t let your record lower a resume point set after that instant. Omitting it is safe but blunt: an absent value is read as an unbounded blind spot, so the record may only ever advance the resume point, never rewind it.
reason | retryable | Meaning |
|---|---|---|
invalid | false | The record’s own shape is wrong. Fix it; resubmitting unchanged cannot help. |
too_old | false | endedAt is past the 90-day acceptance window. |
too_far_future | true | endedAt is more than 5 minutes ahead of server time; succeeds once server time catches up. |
write_failed | true | A server-side write failed and rolled back. Nothing was stored. |
not_found | false | The item is gone or inaccessible to this user. |
unsupported_media_type | false | This endpoint cannot record that kind, including music tracks. |
not_processed | true | No terminal result was produced; retain the record and retry. |
| Limit | Value |
|---|---|
| Records per request | 50 |
Oldest accepted endedAt | 90 days |
| Clock skew tolerated (future) | 5 minutes |
Audiobooks work through this same endpoint. The server fans each record out to
whichever store owns the resume point (AudiobookShelf, for a book) before the
response returns. See Audiobook playback for
the audiobook-specific rules. Music isn’t accepted on this endpoint: finished music
plays that had no live session go to POST /api/v1/play-sessions/replay.
5. Network speed test
Section titled “5. Network speed test”An on-demand link measurement for the “is it my connection?” question. The measurement is taken on the receiver: a server-side timer would measure the write into a socket buffer, not what the viewer actually got.
GET /api/v1/network/speed-test?bytes=16777216 # 1 MiB – 64 MiB, default 16 MiBFold arrivals into 250 ms buckets, then post the result:
curl -X POST https://lumastra.example.com/api/v1/network/speed-test/results \ -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \ -d '{ "mode": "cadence", "bytesRequested": 33554432, "bytesReceived": 33554432, "durationMs": 48210, "ttfbMs": 41, "buckets": [], "requests": [{ "bytes": 4194304, "ttfbMs": 41, "durationMs": 5120 }] }'Three profiles: single (one 16 MiB download), long (one 64 MiB download),
and cadence (8 sequential 4 MiB fetches, 2 s apart). cadence is closest to
real segment fetches and worth implementing first. Rate-limited to 6
posts/minute; the payload endpoint’s own limits are split by size (≤4 MiB: 40
requests/minute; larger: 6/minute).
Related but passive: emit a delivery_window event on
POST /api/v1/playback/telemetry every closed 30-second window of real segment
downloads, so an admin looking at a stall can see the link’s state without
asking the viewer to run anything.
6. What to play next
Section titled “6. What to play next”GET /api/v1/series/{seriesId}/next-episode returns the next unwatched episode,
accounting for any in-progress session. To fetch one episode by position, use
GET /api/v1/series/{seriesId}/seasons/{n}/episodes/{e}. Feed the Continue
Watching rail from GET /api/v1/continue-watching.
For autoplay after a file ends, use ?afterPlayback=<episode UUID>. This
advances past every episode sharing that episode’s default file in the active
series ordering, independently of watch history. A combined E1–E2 file therefore
advances to E3 from either entry point. The lookup skips unavailable files and
returns 404 not_found when no next episode is available.
Next-episode prompt
Section titled “Next-episode prompt”Show the next-episode card from two playback preferences, read from
GET /api/v1/me/playback-preferences.
nextEpisodePromptLeadSeconds(−60 to 120) is the number of seconds before the start of the credits, which is theOutromedia segment. A negative value shows the card after the credits have started. When it isnull, the instance default applies. The server setting isNEXT_EPISODE_PROMPT_LEAD_SECONDS, and it is 0 out of the box. The instance default is not exposed on/api/v1, so a v1 client with anullpreference should assume 0.- Without an
Outrosegment, usenoOutroFallbackSeconds(0 to 120, default 30): show the card that many seconds before the end, and never before the midpoint of the item. A value of 0 turns the fallback off.
In a Watch Together party, only the host’s card
advances the party. A guest shows no card and waits for the host’s navigate.
7. Live event stream
Section titled “7. Live event stream”GET /api/v1/events is the one global per-user SSE push channel. Open it
after login; keep it open for the session.
EventSource cannot set an Authorization header, and api_key query parameters are not
accepted. Read the stream with fetch() and a bearer header, then parse the event: and
data: lines yourself. A browser client whose session cookie is set can use
new EventSource('/api/v1/events', { withCredentials: true }) instead.
const res = await fetch('/api/v1/events', { headers: { Authorization: `Bearer ${accessToken}` },});// res.body is a stream of SSE frames: `event: <name>` plus a JSON `data:` line.// Dispatch on <name>: connected, device.command, admin, and the others below.Every frame is event: <name> plus a JSON data: line: subscribe to name
verbatim. The per-install list is advertised at
GET /api/v1/server → capabilities.events.kinds[].
event: | Class | Durable |
|---|---|---|
connected | bootstrap | — |
admin | signal | no |
device.command | signal | no |
device.command_status | signal | no |
device.state | signal | no |
session_changed | signal | no |
watch_state_set_changed | signal | no |
library.new_episode | notification | yes |
request.available | notification | yes |
admin.broadcast | notification | yes |
A signal means “your view of something is stale, refetch”; missing one is
harmless. A notification also writes a durable row. If durable is true,
also poll GET /api/v1/notifications so an offline client still sees it.
event: admin: toasts and maintenance notices
Section titled “event: admin: toasts and maintenance notices”Discriminate on data.type: admin_message (a perishable operator toast),
maintenance_notice (the pinned banner, carrying an absolute endsAt epoch-ms
so a client that connects mid-window can compute remaining time), or
maintenance_cleared. severity and durationMs are advisory only: decode an
unrecognised severity as info, and clamp durationMs to what your UI can
honour. event: admin frames carry no id: and are never replayed on
reconnect. A toast replayed forty minutes late is worse than a dropped one.
Maintenance state, unlike the toast, is re-sent as a snapshot immediately
after connected, and can also be read without a stream at
GET /api/v1/server/notice.
An admin_message targets a playSessionId or deviceId. The server sends
it to every connection on the account, and the client filters: render it
when neither field is set (broadcast, which is what maintenance_notice
always is), or when one field matches this client; drop it otherwise. One
account can have several clients connected (two Apple TVs, a phone, a
browser), and honouring the target avoids one extra toast on the others.
Notification-class frames carry an id: line; the server replays what was missed after a
reconnect that sends the last one back as Last-Event-ID. event: admin frames are the
exception: no id:, never replayed (see above).
Announce what you can render
Section titled “Announce what you can render”POST /api/v1/devices/{id}/heartbeat{ "features": ["admin_message", "maintenance_notice", "watch_together"] }The admin UI hides actions a client would silently swallow, so a client that
does not announce support is never sent one. This is a feature list, not a
version check: tvOS, iOS, and web version independently. It generalizes the older
supportsWatchTogether boolean on the same call, which wins if both are sent. Unknown
tokens are ignored, never rejected.
This global channel is separate from the scoped ones: play-sessions/{id}/events
(§1) and Watch Together’s own session channel, which have
their own resource lifecycle. The session-scoped channel also carries the
admin_message/maintenance payloads as bare data: frames (no event: name);
if you read both, de-duplicate on the frame’s id before rendering.
- Live TV playback: the same play-session model over a live channel.
- Watch Together: synchronized playback built on top of this same per-device model.