Skip to content

Offline downloads

For a movie or episode, choose between the original physical file and a server-encoded rendition. Both routes below use the /api/v1 prefix:

Original fileRendition
EndpointGET /media/{id}/downloadGET /media/{id}/renditions → POST a rung → poll → download
What you getThe untouched source file, byte for byteA newly encoded H.264/AAC MP4 at a quality you pick
Server workRead and transfer the existing bytesEncode and cache each requested quality
When to use itThe device can direct play the sourceIt cannot: 4K HEVC/TrueHD on a phone, say
Availabilitycapabilities.features.mediaDownloadcapabilities.features.downloadRenditions

Check that your local player can decode the original container, video and audio before saving it. A rendition provides H.264 High, 8-bit SDR video and AAC-LC stereo audio in MP4; a source without audio produces a video-only MP4. Test that combination on your target device too. For music and audiobooks, use the audio flows below.

Choose a complete physical release with a fileId from playability. Save a file only as the full release it is, never as part of an episode.

Show an exact size when known and label estimates explicitly. Unknown size is not zero; your client must decide whether to ask the user or defer the transfer.

For the original file, each entry in versions[] from POST /api/v1/media/{id}/playability carries sizeBytes, which is optional: absent means the file has not been scanned yet, not zero. The old GET /api/v1/playback/{id}/info has been removed and answers 410 route_retired; use the probe. For a rendition, the ladder endpoint below returns an estimate per quality before anything has been encoded. durationMs is in milliseconds; sizeBytes and estimatedSizeBytes are bytes. A ready rendition reports its actual sizeBytes.

For a successful full original/rendition byte response, Content-Length is the full file length. For 206, it is only that response’s range length; read the full length from Content-Range. The original-file route fails if it cannot obtain a usable file size. These guarantees do not extend to live audio encodes or upstream audiobook responses, where length can be unknown. An absent length needs indeterminate progress.

GET /api/v1/media/{id}/download
Authorization: Bearer <token>

Send ?fileId=<physical file UUID> to pin the exact release the user chose. Omission selects the default eligible complete file. An explicit invalid, foreign or grouped-part fileId returns 404 on this route; it does not silently download another edition. mediaSourceId belongs to the Jellyfin compatibility adapter and is not a v1 selector. The response is Content-Disposition: attachment with a filename rebuilt from catalog metadata, for example Example Series - S01E01 - First Episode.mkv. The original container and every embedded track remain unchanged. No play session or playback grant is needed for this transfer, and fetching bytes does not record watching.

A rendition is one progressive MP4, at one quality, with at most one audio track. HDR sources are tone-mapped to SDR. Subtitles and attachments are omitted; source chapters are copied. For a silent source, audioIndex is null and audioBitrate is 0. Renditions are shared: requests for the same file, rung and audio track join the same encode and cached artifact. Access is checked separately for each caller.

GET /api/v1/media/{id}/renditions?fileId=b5e72b98-2c43-4b7c-8d21-5ae7af7ca343&audioIndex=1
Authorization: Bearer <token>

Pass the physical fileId on both GET and POST. Check the listing’s returned fileId against your selection: the current rendition handlers can fall back to the default complete file when an explicit selection no longer resolves. Stop and refresh playability on a mismatch; never assume it encoded the requested edition. The POST response does not echo fileId, so a file-selection change between listing and creation cannot be verified from that response alone. Refresh rather than retrying a stale selector.

{
"fileId": "b5e72b98-2c43-4b7c-8d21-5ae7af7ca343",
"durationMs": 300000,
"sourceHeight": 720,
"audioIndex": 1,
"rungs": [
{ "id": "720p", "label": "720p", "height": 720,
"videoBitrate": 2500000, "audioBitrate": 128000,
"estimatedSizeBytes": 100521000, "status": "absent" },
{ "id": "540p", "label": "540p", "height": 540,
"videoBitrate": 1200000, "audioBitrate": 128000,
"estimatedSizeBytes": 50796000, "status": "absent" },
{ "id": "360p", "label": "360p", "height": 360,
"videoBitrate": 600000, "audioBitrate": 96000,
"estimatedSizeBytes": 26622000, "status": "absent" }
]
}
  • videoBitrate and audioBitrate are bits per second. Estimates use their sum × duration ÷ 8, plus 2% container overhead. Actual encoded size can vary; do not treat an estimate as a hard storage bound. Unknown duration gives a null estimate.
  • Rungs normally fit the source height. For a source shorter than 360 pixels the smallest rung is still offered and encoding keeps the source height. Unknown height offers the full ladder. Use returned rung IDs instead of assuming a fixed list.
  • status is absent before a row exists, then queued, encoding, ready or failed. Existing rungs include renditionId and progressPct; ready ones can include sizeBytes.
  • audioIndex is the absolute stream index (tracks.audio[].absoluteIndex in playability), not the display index. Omission selects the default audio track when audio exists; it does not request mute. Each audio selection has its own rendition.
POST /api/v1/media/{id}/renditions
Authorization: Bearer <token>
Content-Type: application/json
{ "rung": "720p", "fileId": "b5e72b98-2c43-4b7c-8d21-5ae7af7ca343", "audioIndex": 1 }
// 202 Accepted — an encode started, or you joined one already running
{ "id": "4fea7c16-dd56-40a3-9a05-507e6494e089", "status": "queued", "progressPct": 0, "rungId": "720p", "audioIndex": 1 }

200 means it was already built and is ready to download right now. The call is idempotent: requesting the same quality again returns the existing rendition rather than starting a second encode; requesting a failed one retries it.

GET /api/v1/renditions/{renditionId}
Authorization: Bearer <token>

Automatic series-download candidates advertise prepareRePostSeconds on GET /api/v1/series/downloads/candidates. For that pool workflow, a queued preparation may need the original rendition POST repeated after that delay; polling alone does not restart it. Retain the original request parameters and follow the API reference for the pool contract.

url appears only when status is ready. Don’t construct it yourself from the id. On failed, inspect errorCode (for example encode_failed or encode_stalled) and offer a bounded retry by repeating the original POST. An encode failure is a rendition state, distinct from an HTTP refusal of the POST.

GET /api/v1/renditions/{renditionId}/file
Authorization: Bearer <token>

Served as an attachment named after the title with the quality appended: Example Series - S01E01 - First Episode (360p).mp4.

The original-file and rendition-file routes support a single byte range and return Accept-Ranges: bytes, a strong ETag and normally Last-Modified. Save the ETag with the partial file and send it back verbatim:

GET /api/v1/renditions/{id}/file
Authorization: Bearer <token>
Range: bytes=10485760-
If-Range: "17b8824-19ff17949d5"
What you sentWhat you get
Range alone206 with Content-Range
Range + matching If-Range206, resume proceeds
Range + stale If-Range200 with the whole file
Range past the end416 with Content-Range: bytes */<size>

Only append a 206 whose Content-Range begins at the requested offset and whose validator matches your saved representation. A 200 replaces the partial file in full. For 416, reconcile the reported length and validator before treating a local file as complete. The ETag is derived from size and modification time; it is not a content hash.

Fence asynchronous completion locally too: associate each transfer with its media ID, physical file ID, rendition/audio choice and a local attempt generation. A completion from a cancelled or replaced attempt must not mark the current selection downloaded. There is no download generation request parameter. A smart-pool rescore’s generation identifies a ranking snapshot, not the byte representation.

These URLs have no single-use token or fixed expiry. Every request needs the bearer credential, scope and current title access. Refresh an expired access token and retry the same URL with the saved If-Range. Playback ?grant= credentials do not authorize these download routes. A receiver that cannot send bearer headers cannot use them; never give it your account token to work around that. Use the separate casting flow for network playback.

Finished renditions live in a cache with a byte budget, not an expiry: the coldest artifacts are evicted, and eviction removes the rendition entirely. Status polls and byte requests refresh its last-used time, reducing eviction risk; this does not reserve the artifact. An evicted quality reads absent again and its file URL answers 404, including between a status poll and the next range request. Refresh discovery/access and the ladder before requesting it again: 404 can also mean access was revoked or the feature disabled. If still offered, POST to rebuild. Saved local bytes remain local; cache eviction does not remove a completed device download.

A bounded worker pool limits active rendition encodes. Interactive POSTs omit pool and are rate-limited per user. Automatic sweeps must send a nonempty pool lane name; those POSTs instead face a per-user pending-preparation cap and global queue backpressure. Either capacity check can return 503 with Retry-After. Interactive requests bypass these pool checks, but still face validation and request-rate limits. Back off according to the response rather than retrying a season in a tight loop.

An operator can also cap bandwidth per response, separately for downloads and for transcoded streams. When a limit is set your transfer is just slower: there’s no header advertising it and nothing for a client to do differently.

For automatic series pools, /api/v1/series/downloads/candidates advertises maxConcurrentDownloads (0 means start no byte transfers), maxConcurrentPreparations, prepareRePostSeconds and prepareQueueDepth. Honor these separately: preparing an encode is not a byte transfer. Other worker, queue, bandwidth and cache settings remain operator configuration; see Integrations.

Handle refusals separately from encode failures

Section titled “Handle refusals separately from encode failures”
ResponseClient action
401 (unauthorized / invalid_token)Obtain a valid account credential, then retry.
403 scope_requiredRequest the required scope; repeated byte retries cannot fix it.
404 not_foundRefresh discovery, access and selection; it can also mean a disabled feature or evicted rendition.
422 validation_failed on rendition GET/POSTInspect details[].field: a missing audio track, unknown rung or rung unavailable for the source needs a new selection.
429 rate_limited on interactive rendition POSTWait for Retry-After, in seconds. The current limit is 60 requests per user per 60-second window.
503 dependency_unavailableBack off; pool capacity refusals include Retry-After. A dependency failure may need server recovery.

Read the problem response’s code and structured details, rather than matching its human-readable error. This download validation is separate from playback-start refusals such as source_incompatible, transcode_not_allowed and stream_limit. Do not silently fall back to an original file your device cannot decode.

Renditions omit subtitle tracks. The original file retains embedded subtitles, but external sidecars need their own download. Fetch the selected file’s subtitle links while online, store them with the media and render them locally. A playback start’s delivery.subtitles[].hrefs provides text (WebVTT), raw (original format) and, where available, rendered links. Bitmap rendering may require a descriptor and image assets; saving only a descriptor URL does not make it usable offline. ASS can need fonts. Prefer a format your offline player actually renders; no rendition request burns subtitles in.

Issued subtitle URLs can carry session-bound grants. Complete those downloads before closing the session or letting its grant expire, and never persist a remote URL as a substitute for local subtitle bytes. See audio and subtitle tracks.

KindDownload bytesRecord actual offline playback later
Movie / episodeOriginal or rendition routes above; no playback session requiredPOST /api/v1/offline-playback-sessions
MusicBearer GET /api/v1/music/tracks/{id}/stream?download=1POST /api/v1/play-sessions/replay
AudiobookPOST /api/v1/play-sessions with audio: { "download": true }, then its issued track URLsPOST /api/v1/offline-playback-sessions

Music’s transfer flag suppresses listening-session creation and leaves existing listening sessions alone. For a complete file, omit startPositionSeconds. Optional format=mp3&bitrateKbps=128 selects an encode; format=m4a produces AAC in MP4 for players that need that container. Raw aac is ADTS and may fail from disk on Apple players. Audio bitrate controls are kilobits per second, unlike the video rendition ladder’s bits per second. Music’s download route is separate from the two video download switches; transcoding depends on the server’s music-transcode feature. It can return 429 with Retry-After (currently 300 download GETs per user per 600-second fixed window) or capacity 503. A live encode may not provide a final size or resumable bytes; do not apply the video-file resume guarantee to every audio response. See Music playback for negotiation and replay.

Audiobook download starts require media:write and create a real session without superseding the user’s listening sessions. They remain subject to stream policy and require the user’s AudiobookShelf link. Save every required track, its book offset and duration; a book may have several files. Follow issued URLs unchanged and inspect the delivery kind rather than assuming one progressive file. Audiobook grants normally last up to 24 hours, or up to 6 hours when unpinned for casting; use the returned expiresAt, renewAfter and renewHref. Renewal needs account authentication. Session termination revokes byte access. castTarget: true requests access from another IP only when server policy allows it; a download flag itself grants no casting permission. See Audiobook playback for session closure and track clocks.

Downloading bytes is not heard or watched time. When playing saved video or audiobooks without a live session, retain a UUID eventId per playback stretch and upload up to 50 sessions to /api/v1/offline-playback-sessions with media:write. Records carry mediaId, startedAt, endedAt, positionMs, runtimeMs, sessionElapsedMs and endReason (stopped or completion), plus knownAsOf when tracked. Set knownAsOf to when you last incorporated server state, so stale offline work cannot overwrite a newer resume point from another device. All durations are milliseconds; active time excludes pauses and buffering. Positions for a combined multi-episode physical file are file-absolute; the server applies episode windows. Audiobook positions are book-absolute.

Use occurrence time, not upload time. Records older than 90 days or more than five minutes in the future are refused individually. Follow each result’s retryable flag; accepted and duplicate are terminal. applied: false on an accepted record means it did not determine current resume state, so re-read state rather than resubmitting it. Music uses replay instead: /offline-playback-sessions rejects tracks as unsupported_media_type. Keep actual online playback on its returned reporting contract; never send transfer progress as listening. See offline playback sync.