# 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](#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](../architecture/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](#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 `, writing to `.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: ```rust // schema.rs comment + DownloadInfo status TEXT DEFAULT 'pending', -- pending, downloading, processing, completed, failed, paused ``` ```rust // 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, }, ``` ```typescript // 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: ```sql 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](desktop-native-video.md)). The webview `