Skip to content

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.

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:

Terminal window
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 }'
  • positionMs is 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.
  • sessionElapsedMs is required: cumulative milliseconds actually played since the session started, excluding pauses, seeks and buffering.
  • eventSeq is required: a per-session, monotonically increasing integer starting at
    1. A stale sequence number is acknowledged and ignored, so retries are safe.
  • status: ACTIVE | PAUSED | COMPLETED. COMPLETED ends the session (204). A terminal outcome can instead return 200; inspect its body. Add endReason: completion | skip | user | unload | error | switch.
  • Optional fields include audioStreamIndex, subtitleStreamIndex, bitrateBps, resolution, playMethod, audioLanguage, subtitleLanguage, subtitleMethod, bufferHealthMs and adaptive.

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.

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.

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.

Terminal window
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.

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.

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.

Terminal window
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. true means keep it queued; false means stop (terminal success or a permanently bad record). Treat this flag as authoritative, not the reason string.
  • endedAt is 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.
reasonretryableMeaning
invalidfalseThe record’s own shape is wrong. Fix it; resubmitting unchanged cannot help.
too_oldfalseendedAt is past the 90-day acceptance window.
too_far_futuretrueendedAt is more than 5 minutes ahead of server time; succeeds once server time catches up.
write_failedtrueA server-side write failed and rolled back. Nothing was stored.
not_foundfalseThe item is gone or inaccessible to this user.
unsupported_media_typefalseThis endpoint cannot record that kind, including music tracks.
not_processedtrueNo terminal result was produced; retain the record and retry.
LimitValue
Records per request50
Oldest accepted endedAt90 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.

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 MiB

Fold arrivals into 250 ms buckets, then post the result:

Terminal window
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.

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.

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 the Outro media segment. A negative value shows the card after the credits have started. When it is null, the instance default applies. The server setting is NEXT_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 a null preference should assume 0.
  • Without an Outro segment, use noOutroFallbackSeconds (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.

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:ClassDurable
connectedbootstrap—
adminsignalno
device.commandsignalno
device.command_statussignalno
device.statesignalno
session_changedsignalno
watch_state_set_changedsignalno
library.new_episodenotificationyes
request.availablenotificationyes
admin.broadcastnotificationyes

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).

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.