Streaming a player
This continues Building a player once you know which item to play. It covers everything between “I want to play this” and “the video is on screen.”
1. Start playback and get a grant
Section titled “1. Start playback and get a grant”An API access token may expire while a stream is playing, and media pipelines can
fetch nested resources without your API headers. Start playback with
POST /api/v1/play-sessions. The
response carries ready-to-open URLs, each with a scoped grant already attached:
curl -X POST https://lumastra.example.com/api/v1/play-sessions \ -H "Authorization: Bearer <access_token>" -H "Content-Type: application/json" \ -d '{"mediaId":"550e8400-e29b-41d4-a716-446655440000","deviceId":"my-tv-app-uuid", "capabilities":{"containers":["mp4","ts"],"videoCodecs":["h264","hevc"], "audioCodecs":["aac","ac3"],"subtitleFormats":["vtt"],"hdr":["hdr10"]}}'mediaId and deviceId are required. capabilities and every field inside it are
optional; an omitted list takes its default (see Building a player).
startPositionMs overrides the saved resume point. Starting needs the media:write
scope and is limited to 60 requests per minute per user.
// 201 Created (abridged){ "playbackSessionId": "uuid", "mediaId": "550e8400-…", "kind": "video", "delivery": { "kind": "hls", "url": "/api/v1/playback/550e8400-…/hls?PlaySessionId=…&grant=…", "startPositionMs": 0, "subtitles": [ /* see section 4 */ ], "timeline": { "href": "/api/v1/playback/550e8400-…/timeline?grant=…" } }, "grant": { "id": "uuid", "token": "…", "param": "grant", "mediaId": "550e8400-…", "playbackSessionId": "uuid", "kind": "video", "ipPinned": true, "expiresAt": "…", "renewAfter": "…", "renewHref": "/api/v1/playback/grants/{id}/renew" }, "reporting": { "kind": "progress", "method": "PUT", "playbackSessionId": "uuid", "href": "/api/v1/play-sessions/{id}", "intervalMs": 10000, "unit": "ms" }}Every URL in delivery (the HLS manifest or file, subtitles, timeline, previews) is
ready to use and already carries ?grant=. Resolve relative URLs against the
configured server origin, preserving path and query. Send bearer headers only to your
configured API origin; never forward them to an arbitrary delivery host. Don’t append
your account token or build media URLs yourself. The server never puts your account credential into a URL.
A grant belongs to one item and one playback session, and works only on media URLs, never on JSON API calls. The first grant lasts the full limit for its kind: 24 hours for video and audiobooks, 6 hours for music and Live TV. The server also embeds it in every segment URL of the HLS master playlist, so AVPlayer fetches segments without setting headers.
By default a grant is pinned to the IP address that started the session
(grant.ipPinned). To hand the URL to a cast or AirPlay receiver on another address,
send "castTarget": true in the start request. The server then mints an unpinned grant
if the operator’s policy allows it. Each unpinned grant issuance or renewal extends
at most 6 hours from that request, within the overall lifetime limit.
Renewal is optional. A client that can send headers renews before grant.renewAfter:
curl -X POST https://lumastra.example.com{grant.renewHref} \ -H "Authorization: Bearer <access_token>" -H "Content-Type: application/json" \ -d '{"token":"<grant.token>"}'The response is the grant object with the extended expiry. The grant token stays the
same, so already-issued media URLs keep working. Renewal rechecks the user’s
access to the item, allows 30 requests per minute per user, and never shortens expiry.
No grant lives longer than 7 days after its first start. GET and DELETE on
/api/v1/playback/grants/{id} read and revoke a grant by its id; the secret is never
part of that path.
A grant ends with its playback session. Ending the session with PUT status: "COMPLETED"
or DELETE /api/v1/play-sessions/{id}, a newer start on the same device, or reaping makes every URL on that
grant answer 401 invalid_grant with reason: session_ended. Start a new session to
play again.
2. Open the stream
Section titled “2. Open the stream”Open delivery.url exactly as the start returned it. For a file, delivery.kind is
file; for HLS it is hls. The URL already holds the grant and the PlaySessionId
(the playbackSessionId) the server put into it.
Stream URLs are opaque: their shape depends on how the server serves the stream (a
source copy, an adaptive ladder, a capped rung, a transcode job) and changes between
releases without notice. Never build a stream URL from an id, never parse or rewrite
one, and never rely on a path segment. The quality caps maxVideoBitrateBps, maxHeight and
maxAudioChannels are pre-filled into the returned URL by the start call.
To start on a given audio or subtitle track, send audioStreamIndex (absolute index)
and subtitleId (the stable id from delivery.subtitles[], or "none" to disable
subtitles) in the POST /play-sessions body; the
returned delivery.url already reflects the choice. Mid-play audio switching uses the
HLS renditions only when delivery.hlsAudioMode is demux. A muxed stream carries
its audio in the video segments; changing audio requires a reload. Declare
capabilities.hlsCopyReload: true only if your player implements that reload behavior.
A fresh start with the chosen track and the current position is the portable way to
change a selection that needs a new server plan. It supersedes the old session on that
device; use the new URLs and reporting ID. Read delivery.applied for the actual
selection, since a stale audio index may fall back to the default. Subtitles come from
delivery.subtitles[] (§4).
An HLS master lists the renditions available for that session. Demux audio uses
#EXT-X-MEDIA; video uses #EXT-X-STREAM-INF. Subtitles, I-frame and image playlists
appear only when available. Check outputHdr for delivered HDR and transcodeCodec
for the full-transcode target instead of inferring them from the source file. Every
segment URL already carries the grant.
// HLS.js (browser)const hls = new Hls(); // the grant in the URL authenticates manifest and segments// session = the JSON from POST /api/v1/play-sessionshls.loadSource(new URL(session.delivery.url, location.origin).toString());hls.attachMedia(videoElement);3. Seeking & errors
Section titled “3. Seeking & errors”Video HLS presents a VOD timeline. Seek using your player’s normal time API; the
server decides whether cached segments can satisfy the request or encoding must restart.
A backward seek does not always restart encoding. Do not construct a new URL with a
seek offset or translate segment numbers yourself. On a stale/evicted segment (410),
reload the issued manifest and restore the intended position. On 401 invalid_grant,
inspect the grant failure before attempting a reload.
For a file or HLS video delivery, honor delivery.seek.clientShouldSeek and seek to
delivery.startPositionMs. A combined multi-episode file uses the full file clock,
including its entry offset.
Resume an episode explains the
difference. Never restart the clock at a part boundary.
| Operation | Timeout |
|---|---|
| Master playlist (initial startup) | 30 s |
| Segment, normal sequential playback | 10 s |
| Segment, immediately after a seek | 45 s |
// 503 — transcoder did not produce the manifest in time (application/problem+json){ "code": "dependency_unavailable", "error": "HLS transcode startup timed out", "status": 503, "reason": "transcode_start_timeout" }// Other startup reasons: transcode_start_failed, transcode_start_interrupted.// Retry the same request after a delay; when a Retry-After header is present, wait that long.4. Audio & subtitle tracks
Section titled “4. Audio & subtitle tracks”Audio: select by absoluteIndex, the wire value on every API (audioStreamIndex
on the start request and on progress reports). Mixing it up with the
display-only index is the classic “wrong audio language” bug.
Subtitles: select by stable id. Do not persist a sidecar’s synthetic index,
which can change when sidecars are added. Playability distinguishes ass in its
track format; delivery descriptors classify it as text. The start response lists tracks in
delivery.subtitles[]:
{ "id": "550e8400-e29b-41d4-a716-446655440001:3", "index": 3, "kind": "embedded", // embedded | external "codec": "srt", "language": "eng", "title": "English (SDH)", // optional "default": false, "forced": false, "format": "text", // text | bitmap "method": "external_vtt", // external_vtt | client_render | burn_in | none "hrefs": { "text": "/api/v1/playback/550e8400-…/subtitles/3?grant=…" }}hrefs.textis WebVTT for text tracks.hrefs.rawis the original format.hrefs.renderedexists only for bitmap tracks (PGS and VobSub embedded in the file). Bitmap sidecar files have no hrefs.- Every href is an
/api/v1URL that already carries?grant=, so it works for header-less players and for bearer clients alike. - Pass
idback assubtitleIdin the start request to select or burn in a track. Send"none"to disable subtitles. Inspectmethod:external_vttsupplies text,client_renderrequires your renderer,burn_inis already encoded into the video, andnonecannot be selected. Unsupported sidecars are not automatically burned in. delivery.recommendedgivesaudioStreamIndexandsubtitleIdfrom the user’s language preferences. It is absent when the server has no opinion. Thedefaultandforcedflags on each track are the file’s own.
Bitmap subtitles (PGS / VobSub)
Section titled “Bitmap subtitles (PGS / VobSub)”Blu-ray (PGS) and DVD (VobSub) subtitles are pictures, not text. Use hrefs.rendered
for pre-rendered images, which still require an overlay renderer in your client. Or
fetch hrefs.raw and decode
the bitmaps yourself, for example with libpgs-js. Burn-in forces a full transcode. The
first request for a track is slow (a minute or two on a two-hour remux, since nothing is
pre-rendered); afterwards it is served in well under a second and kept long-term. DVBSub
and XSUB have no rendered form; burn them in.
5. Preview sessions (ambient hover previews)
Section titled “5. Preview sessions (ambient hover previews)”A preview is a short, muted, throwaway clip for browse UI. It is a separate lane from real playback: it opens no play session, writes no watch state, and never moves the resume position.
# Create — mediaId is the only required fieldcurl -X POST https://lumastra.example.com/api/v1/playback/previews \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{ "mediaId": "0f8b…" }'# → 201 { "id": "…", "streamUrl": "…/index.m3u8", "hlsStartPosition": 0,# "reporting": { "kind": "keepalive", "method": "POST", "href": "…", "intervalMs": 10000 } }
curl -X POST <reporting.href> -H "Authorization: Bearer $TOKEN" # every reporting.intervalMs → 204curl -X DELETE .../api/v1/playback/previews/{id} # release the moment focus moves away → 204Play at the returned hlsStartPosition (the playlist is already seeked
server-side), and wait for ~1.5–2 s of dwell before creating a preview. If
your browse UI auto-advances, the dwell must exceed the rotation interval, or an
unattended carousel spends one create→play→delete cycle per slide indefinitely.
Prefer holding a session briefly (under the 30 s heartbeat timeout) over
deleting it the instant focus leaves, since streamUrl embeds a per-session id
and re-creating it re-downloads bytes the device already has.
| Limit | Default | Behaviour |
|---|---|---|
| Concurrent previews per user | 6 | Creating one more evicts the coldest |
| Heartbeat timeout | 30 s | No keepalive POST → reaped |
| Absolute session TTL | 5 min | Reaped regardless of heartbeats |
| Clip length | 60 s | Server-capped |
POST /api/v1/playback/previews returns 404 when FEATURE_MEDIA_HOVER_VIDEO_PREVIEW
is off (Live TV previews additionally need
FEATURE_MEDIA_HOVER_VIDEO_PREVIEW_LIVETV). Degrade to the backdrop image
instead of treating it as a hard failure.
6. Self-contained preview URLs (Top Shelf & carousels)
Section titled “6. Self-contained preview URLs (Top Shelf & carousels)”The hover-preview lane above needs a live app process. Some platform surfaces, tvOS Top Shelf among them, hand two plain URLs to a system player that cannot set headers, cannot heartbeat, and opens no session. This lane serves that case with one self-authenticating, heartbeat-free MP4 URL baked into the snapshot.
curl "https://lumastra.example.com/api/v1/media/{id}/topshelf-preview" -H "Authorization: Bearer $TOKEN"{ "status": "ready", "previewVideoUrl": "/api/v1/media/{uuid}/topshelf-preview/file/preview.mp4?expires=…&sig=…", "cinemagraphUrl": "/api/v1/media/{uuid}/topshelf-preview/file/cinemagraph.mp4?expires=…&sig=…", "expiresAt": "2026-07-24T10:00:00Z"}status | HTTP | What to do |
|---|---|---|
ready | 200 | Bake the URLs into the platform snapshot |
pending | 202 | Render is warming: use artwork this cycle, don’t tight-poll |
unavailable | 200 | No renderable source, artwork only |
The URLs are path-relative, HMAC-signed: no Bearer header or grant
needed, valid until expiresAt (~48 h). Gate on
capabilities.features.topShelfPreviews; re-mint on every snapshot rewrite,
since repeat mints reuse the cached artifact.
7. Trickplay, chapters & skip markers
Section titled “7. Trickplay, chapters & skip markers”Chapters, skip segments and trickplay come from delivery.timeline.href. The response
carries:
durationMs.chapters[]:startMs,endMs,nameandimage.href.segments[]:typeisintro,outro,recap,previeworcommercial, withstartMsandendMs.trickplay:stateandvariants[](width,manifestHref).
The timeline link is optional; do not assume it is present.
All times are integer milliseconds on the selected file’s clock. trickplay.state is
ready, pending (variants is empty; ask again later), unavailable (retry later) or
unsupported (never retry).
Show the skip button while the position is inside a segment, and seek to its endMs.
The trickplay manifest is JSON (tileColumns, tileRows, intervalMs, thumbnailCount,
sheets[] with hrefs); fetch the sheets as given. The HLS master playlist also carries
trickplay images as #EXT-X-IMAGE-STREAM-INF (Roku) and #EXT-X-I-FRAME-STREAM-INF
(Apple TV), and GET /api/v1/playback/{id}/trickplay/{width} serves the image sheets directly.
- Progress & state: play sessions, watch state, and the live event stream.
- Offline downloads: original files and transcoded renditions.
- Live TV playback: the same streaming model over a live channel.