Files
jellytau/docs/specs/on-device-audio-remux.md
T
dtourolle 0f928798d7 docs(specs): download the original and fix the audio on device
A Jellyfin transcode is generated as it is sent — no Content-Length, Range
ignored — so every interruption restarts it from byte zero, and three
concurrent downloads are three ffmpeg jobs on the server. Measured on the
tablet: a direct copy moves 2.06 GB in 142 s with no retries, a transcode
crawls at ~1 MB/s and cannot resume. The transcode is only ever requested
because of the audio track.

So: always fetch Static=true, and re-encode the audio on the device when the
source carries something the renderers cannot decode.

DR-171 keeps its diagnosis — a downloaded film played as picture in silence,
and offline there is no other source to fall back to — and loses its remedy,
which explicitly accepted the loss of byte-range resumability. Its other
finding survives and constrains this spec: a downloaded file outlives whatever
experimentalNativeVideo was set to when it arrived, which is why the fix is to
the bytes on disk rather than to one renderer.

Records the rejected alternatives with the specific reason each fails, and the
decision to accept HEVC staying HEVC (Android-first; recoverable by a setting,
unlike silent audio).
2026-09-22 21:00:32 -04:00

18 KiB
Raw Blame History

Spec: downloads fetch the original file and fix the audio on device

Status: Proposed Requirements: UR-071, UR-004 → DR-291 (new). Revises DR-171, which keeps its diagnosis and loses its remedy — see Relationship to DR-171. UX spec: n/a — one new row state in the existing transfers list. Destination on completion: 06-downloads-and-offline.md — a new "Post-download processing" section after the download worker, plus a rewrite of the audio-policy paragraph that currently describes DR-171's server-side remedy.

Summary

A video download always fetches the server's original bytes (Static=true) and, when the source carries audio this app's renderers cannot decode, re-encodes that audio on the device with a bundled FFmpeg instead of asking the server to transcode the whole film on the fly. Downloads become byte-range resumable again — the property that makes them reliable — and the server stops spending a CPU-hour per saved film.

Motivation

A Jellyfin transcode is generated as it is sent: chunked, no Content-Length, and Range ignored. Every interruption therefore restarts it from byte zero, and with the queue at three concurrent downloads it is also three FFmpeg jobs on the server.

Measured on the ROD2-W09 tablet against the production server (2026-09-22):

Download kind Throughput Retries Resumable
Static=true direct copy 2.06 GB in 142 s (~14.5 MB/s) 0 yes (HTTP 206)
Server transcode ~1 MB/s restarts from 0 on any blip no (HTTP 200)

That is a ~14× throughput difference, and the transcode's failure mode is unbounded: three retries × a full restart each is the whole file fetched four times, which is what DR-289 was masking and DR-290 was papering over.

The transcode is only ever requested because of the audio track. Roughly every AC-3/E-AC-3 film hits it on this device (no audio/ac3 or audio/eac3 decoder in MediaCodecList), so on a typical library the slow, non-resumable path is the common one, not the exception.

Relationship to DR-171

DR-171 is right about the defect and wrong about the remedy, and this spec keeps the first half intact.

The defect: Static=true hands back the source untouched, E-AC-3 track included, and a downloaded film played as picture in silence while the same film had sound when streamed. Offline, the download is the only source a video has, so there is no working path to fall back to.

DR-171's remedy was to ask the server for a transcode, and it explicitly accepted the cost: "the transcode costs the byte-range resumability Static=true gives the download worker". That cost is now measured, and it is the dominant one.

DR-171 also chose to judge the codec against the webview's list rather than the device's, reasoning that "a downloaded file outlives whatever experimentalNativeVideo was set to when it arrived". That reasoning survives this spec and constrains it: whatever lands on disk must play on either renderer, on either platform, years after the setting that was active when it arrived. It is the reason this spec fixes the bytes on disk rather than teaching one renderer to cope (see Rejected alternatives).

Layer assignment

Logic / responsibility Layer Why it belongs there
Whether a source's audio needs re-encoding at all Rust Domain: it compares a Jellyfin-reported codec against renderer capability. Changes when Jellyfin's stream metadata or a platform's decoder set changes. Already Rust (served_audio_codec + webview_can_decode_audio); unchanged by this spec.
Which download URL to request (Static=true vs a transcode) Rust Domain: a Jellyfin route with Jellyfin query semantics. This spec changes the answer (always static) but not the owner.
Target codec/bitrate/channel layout of the local re-encode Rust Domain: it must satisfy the same renderer-capability rule as the streaming profile. A frontend-chosen value would be a second, drifting copy of DR-149's policy.
Running FFmpeg, and the remux invariants (copy video, re-encode audio only, preserve subtitle/chapter streams) Rust Backend owns downloads end to end (CLAUDE.md). It is also the only side that can see the file.
Deciding a row is not yet available offline while processing Rust Business rule about download state. Falls out for free: every offline query already gates on status = 'completed'.
Re-encode progress as a fraction Rust Computed from FFmpeg's reported position against the item runtime — domain data. Emitted like download progress.
Rendering the processing state (label, spinner, bar) Frontend Pure presentation; changes only if the transfers list is redesigned.
Whether to show the transfers list sorted with processing rows first Frontend View ordering preference.

Borderline row, stated with its tie-breaker: "is FFmpeg available on this build?" could read as an environment/presentation concern. It is placed in Rust because the answer changes what URL is requested (a fallback build must go back to asking the server to transcode), and that is a domain decision, not a capability the UI reasons about. The frontend never sees the flag.

Design

Pipeline

resolve  →  download (Static=true, resumable)  →  [needs audio fix?]  →  play
                                                        │ yes
                                                        ▼
                                              remux on device (FFmpeg)
                                              status = 'processing'
                                                        │
                                                        ▼
                                              replace file, status = 'completed'
  1. resolve_video_download keeps judging the served audio codec exactly as today, but the verdict no longer selects a URL. The URL is always Static=true (plus mediaSourceId). The verdict is persisted on the row as needs_audio_remux so the worker does not have to re-fetch the item, and so a resumed download decided before a server change keeps its own verdict.
  2. The worker downloads as it does now. It gets a real Content-Length and HTTP 206 on resume, so DR-290's estimate machinery becomes a fallback rather than the normal case.
  3. On completion, a row with needs_audio_remux = 1 moves to processing rather than completed, and a new download::remux module runs FFmpeg: -c:v copy -c:s copy -c:a aac -b:a 384k -ac <min(source, 6)>, writing to <target>.remux.mp4 and renaming over the original only on success.
  4. The row goes completed with file_size set to the remuxed file's size.

The video stream is copied, so this is I/O plus an audio encode — minutes on a feature film, not the hours a video re-encode would take. Peak disk is ~2× the film for the duration of the remux; the spec's acceptance criteria include refusing to start a remux without that headroom.

Why processing is a status and not a flag

Every offline query already reads status = 'completed' (offline.rs:36, :65, :107; repository/offline.rs:321, :330, :970, :979, :1150). A new status that is simply not completed therefore excludes a mid-remux file from offline browsing with no query changes — a file whose audio has not been fixed yet is exactly as unplayable as one still downloading, and the existing gate already says so.

Wire shapes

Status vocabulary gains one value, on both sides:

// schema.rs comment + DownloadInfo
status TEXT DEFAULT 'pending',  -- pending, downloading, processing, completed, failed, paused
// download/events.rs — the existing kebab-case "download-event" channel
#[serde(rename_all = "camelCase")]
Processing {
    download_id: i64,
    item_id: String,
    /// 0.0..=1.0, from FFmpeg's position against the item runtime.
    progress: f64,
},
// stores/downloads.ts
status: "pending" | "downloading" | "processing" | "completed" | "failed" | "paused";
type:  | "processing";

No new command is needed: the frontend already subscribes to download-event and renders whatever status the row carries. bindings.ts is regenerated from Rust (cargo test export_typescript_bindings), not hand-edited.

The status arrives twice and both payloads carry the new value: once as DownloadInfo.status from get_downloads (the refresh path, e.g. after an app restart mid-remux) and once as the Processing event. A build that taught only the event about processing would show a correct live remux and a row stuck on "Downloading" after any refresh.

New row column:

ALTER TABLE downloads ADD COLUMN needs_audio_remux INTEGER DEFAULT 0;

Packaging FFmpeg

Both platforms link libav* from Rust rather than shelling out to an ffmpeg binary, so the remux is one code path with one set of tests.

  • Android: build a minimal FFmpeg for arm64-v8a (plus the other ABIs the universal APK carries) enabling only what the job needs — decoders ac3,eac3,dts,truehd, encoder aac, demuxers/muxers mov,mp4,matroska, and the copy bitstream path. The size of that build is an open number this spec requires measuring before acceptance, not an estimate to design around; if it exceeds a budget the maintainer sets, drop TrueHD and DTS-HD first (rarest, largest tables).
  • Linux: the AppImage already ships an FFmpeg stack behind libmpv, but those are libmpv's private libraries and must not be linked against directly. Build or link the same minimal set as Android.
  • CI: per CLAUDE.md, the FFmpeg toolchain and the prebuilt libraries live in the builder image (Dockerfile.builder, rebuilt and pushed via scripts/build-builder-image.sh). Nothing is fetched or compiled at job time.

Fallback, so this ships incrementally

If the build has no FFmpeg, or the remux fails for any reason, the item falls back to today's behaviour: request the server-side transcode URL. This means the change can land platform by platform, a remux bug degrades to the current (working, slow) path rather than to a silent film, and the DR-171 defect cannot reappear. The fallback is logged at warn so it is visible rather than silent.

Rejected alternatives

Recorded because each is the obvious next idea and each has a specific reason it fails — this is the half that gets folded into the architecture doc.

  • Teach ExoPlayer to decode AC-3 via media3's FFmpeg decoder extension. Cheaper-looking: no file is rewritten, and it would fix streaming direct-play for the same codecs. It fails DR-171's surviving constraint — a downloaded file outlives the experimentalNativeVideo setting, and on Linux the webview draws video, so the webview path would still be silent. It fixes one renderer; the bytes on disk have to satisfy all of them. Worth doing separately as a streaming optimisation, where the constraint does not apply.
  • Use the device's own MediaCodec to decode AC-3. The test device reports no audio/ac3 or audio/eac3 decoder at all, which is the reason the transcode is requested there in the first place. It is not a decoder we can assume.
  • A pure-Rust decoder (Symphonia). Symphonia does not implement AC-3, E-AC-3 or DTS. There is no pure-Rust path for the codecs that actually matter here.
  • Download the server's HLS transcode segment by segment and remux locally. Segments are individually addressable, so this would restore resumability at segment granularity without decoding anything on device. Rejected because it still spends the server CPU this spec is trying to stop spending, still needs an mp4 muxer on device (most of the same dependency), and HLS transcode sessions expire — a download paused overnight would find its segments gone.
  • Do nothing and raise the retry budget. The transcode restarts from byte zero, so each extra retry is another full-file fetch. More retries buys a linear increase in bytes moved for a fixed, low probability of finishing.

Out of scope

  • Files already downloaded stay as they are. DR-171's closing note applies unchanged: the bytes on disk are the wrong bytes, and only a re-download (or a future one-off "repair downloads" pass) replaces them. If that pass is wanted, it belongs beside the remux module as a command that re-runs it over existing completed rows — noted there, not left to this file, which will be deleted.

  • Streaming. DR-149's server-side transcode for undecodable audio is untouched; a stream has no file to fix and no resumability to lose. The media3 decoder extension above is the lever there.

  • Video re-encoding on device. HEVC sources that the webview cannot render are currently converted to h264 as a side effect of the server transcode. Under this spec an HEVC source with fine audio is copied verbatim and plays on ExoPlayer but not in the webview.

    Decided 2026-09-22: accept it. The project is Android-first until video can be 100% mpv, and both ends of that road decode HEVC — ExoPlayer does today, mpv will on the desktop (desktop-native-video.md). The webview <video> element is the renderer being retired, so spending a full server transcode of every HEVC film to keep a path alive that is on its way out is the wrong trade. The exposure while it lasts: an HEVC film downloaded on Android, then played with experimentalNativeVideo turned off, shows no picture. Unlike DR-171's silent audio this is recoverable without re-downloading — turn the setting back on — which is what makes it acceptable where the audio case was not.

  • Quality presets (high/medium/low) keep asking the server to transcode — they are a deliberate request for smaller files, the server does it better, and their non-resumability is a known cost of a choice the user made.

Acceptance criteria

  • A film whose audio needs fixing downloads over Static=true, reports a real Content-Length, and resumes from a kill -9 with HTTP 206.
  • That film, played offline afterwards, has sound on both ExoPlayer and the webview <video> element.
  • Its row passes through processing and is absent from offline browsing until the remux finishes.
  • A remux failure leaves the row failed with the partial output removed, never completed over a half-written file.
  • A build without FFmpeg still downloads the same film correctly via the server transcode, with a warn naming the fallback.
  • The Android APK size increase is measured and recorded in the PR.
  • bun run check and bun run test pass.
  • cargo fmt clean, cargo clippy --all-targets -- -D warnings clean, bun run test:rust passes.
  • bun run check:boundary passes.
  • New requirement-implementing code carries // TRACES: comments and bun run traces:validate passes.
  • bindings.ts regenerated from Rust.

Testing

Rust (cargo test)

  • The URL builder returns Static=true for every quality-original case, including the codecs that previously forced a transcode — the inverse of today's test_video_download_url_original_transcodes_undecodable_audio, which this spec rewrites rather than deletes (its Static=true assertion for playable codecs stays).
  • needs_audio_remux is persisted from the same verdict the old URL choice used, at all three resolution sites.
  • Remux argument construction is a pure function over (source codec, channel count) and is tested without invoking FFmpeg: video is copied, audio is aac, subtitles survive, no bitrate or scale filter appears.
  • A row in processing is excluded by offline_is_available.
  • Disk-headroom refusal: a remux is not started when free space is under the output estimate.

Frontend (vitest)

  • describeProgress and the transfers row render processing distinctly from downloading — extend downloadProgress.test.ts.
  • The store carries processing through and does not treat it as terminal — extend downloads.test.ts.

On device — the acceptance criteria above are the manual pass; the tablet is the right target because it is the one with no AC-3 decoder.

TRACES

Piece Tag
Always-static download URL + persisted remux verdict // TRACES: UR-071, UR-004 | DR-291 | UT-255
download::remux module (argument construction, invariants) // TRACES: UR-071 | DR-291 | UT-256
processing status + Processing event // TRACES: UR-071 | DR-291 | UT-257
Frontend processing rendering // TRACES: UR-071 | DR-291 | UT-258

Allocate DR-291 in requirements.md (DR-290 is taken by the progress-estimate work) and UT-255UT-258. Re-check the maxima before allocating: the specs index has been stale about this before.

Notes for the implementer

  • A parallel Claude session may be active in this repo. Run git diff before "repairing" unexpected changes — see the CLAUDE.md gotchas.
  • DR-290's estimate code is not dead after this lands: quality presets still transcode server-side, and the FFmpeg-less fallback still exists. Leave it.
  • The remux must not run on the tokio runtime's I/O threads — it is a long CPU job. Use a blocking task, and honour the existing download::stop flag so a cancel during processing is not ignored.
  • Editing Android sources means editing src-tauri/android/src and running scripts/sync-android-sources.sh; never edit the gen/ tree.
  • Do not run ./gradlew directly in gen/android — build through scripts/.