Skip to content

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

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:

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

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

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-sessions
hls.loadSource(new URL(session.delivery.url, location.origin).toString());
hls.attachMedia(videoElement);

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.

OperationTimeout
Master playlist (initial startup)30 s
Segment, normal sequential playback10 s
Segment, immediately after a seek45 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.

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.text is WebVTT for text tracks. hrefs.raw is the original format.
  • hrefs.rendered exists only for bitmap tracks (PGS and VobSub embedded in the file). Bitmap sidecar files have no hrefs.
  • Every href is an /api/v1 URL that already carries ?grant=, so it works for header-less players and for bearer clients alike.
  • Pass id back as subtitleId in the start request to select or burn in a track. Send "none" to disable subtitles. Inspect method: external_vtt supplies text, client_render requires your renderer, burn_in is already encoded into the video, and none cannot be selected. Unsupported sidecars are not automatically burned in.
  • delivery.recommended gives audioStreamIndex and subtitleId from the user’s language preferences. It is absent when the server has no opinion. The default and forced flags on each track are the file’s own.

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.

Terminal window
# Create — mediaId is the only required field
curl -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 → 204
curl -X DELETE .../api/v1/playback/previews/{id} # release the moment focus moves away → 204

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

LimitDefaultBehaviour
Concurrent previews per user6Creating one more evicts the coldest
Heartbeat timeout30 sNo keepalive POST → reaped
Absolute session TTL5 minReaped regardless of heartbeats
Clip length60 sServer-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.

Terminal window
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"
}
statusHTTPWhat to do
ready200Bake the URLs into the platform snapshot
pending202Render is warming: use artwork this cycle, don’t tight-poll
unavailable200No 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.

Chapters, skip segments and trickplay come from delivery.timeline.href. The response carries:

  • durationMs.
  • chapters[]: startMs, endMs, name and image.href.
  • segments[]: type is intro, outro, recap, preview or commercial, with startMs and endMs.
  • trickplay: state and variants[] (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.