feat(android): play the original file — decode Dolby/DTS audio with FFmpeg
Android ships no AC-3, E-AC-3, DTS or TrueHD decoders; they are licensed codecs, present only where a vendor paid for them. The ROD2-W09 tablet has a vendor DTS decoder and no AC-3/E-AC-3 at all. So every film with Dolby audio was re-encoded by the server, for streaming and for download alike, and a transcoded download has no Content-Length and ignores Range: ~1 MB/s, restarting from byte zero on every network blip. ExoPlayer now carries Jellyfin's media3 FFmpeg audio decoder in extension mode ON (platform decoders first, FFmpeg for what they lack), and CodecDetector reports its codecs so the device profile and the download policy agree with what actually decodes. The download policy judges audio against the renderer that will play the file (renderer_can_decode_audio) instead of the webview's list, so Android downloads are always the direct copy — a 910 MB E-AC-3 5.1 episode downloaded in 94 s and played offline. The webview video path is removed on Android: it decodes none of these codecs, so a stored "native video off" would play every original-file download silent. Rust reports webview_video_fallback (false on Android, true only beside mpv native video on Linux); Settings offers the switch and the player honours it only then. Linux keeps the fallback and, with it, the server transcode for undecodable audio. The decoder is GPL-3.0; the distributed APK carries its terms and the source stays MIT (THIRD_PARTY_NOTICES.md). The on-device remux spec this replaces is folded into 05-platform-backends.md and deleted. DR-293, UT-259, UT-262.
This commit is contained in:
@@ -95,8 +95,9 @@ flowchart LR
|
||||
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
|
||||
`src-tauri/src/commands/player/timers.rs`
|
||||
|
||||
Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an
|
||||
HTML5 `<video>`/HLS element **inside the webview**. libmpv is initialized audio-only (`vo=null`,
|
||||
Video on desktop (Linux WebKitGTK) is rendered by an HTML5 `<video>`/HLS element **inside the
|
||||
webview**. Android no longer uses this path for video — see *The webview is not a video renderer on
|
||||
Android* below. libmpv is initialized audio-only (`vo=null`,
|
||||
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
|
||||
the real player, living outside Rust's reach.
|
||||
|
||||
@@ -287,6 +288,50 @@ and the trait default is still a silent `Ok(())` rather than an error, so a back
|
||||
that omits the method still reports success. Flipping that default waits on the
|
||||
device verification.
|
||||
|
||||
### Licensed audio codecs: the FFmpeg extension
|
||||
|
||||
**TRACES**: UR-004, UR-071 | DR-293
|
||||
|
||||
Android does not ship AC-3, E-AC-3, DTS or TrueHD decoders — they are licensed
|
||||
codecs, present only where a vendor paid for them. The ROD2-W09 test tablet has a
|
||||
vendor DTS decoder and no AC-3/E-AC-3 at all. ExoPlayer has no decoders of its
|
||||
own, so on such a device those tracks are undecodable, and before this every film
|
||||
with Dolby audio was re-encoded by the server — for streaming *and* for download.
|
||||
|
||||
`JellyTauPlayer` builds ExoPlayer with `DefaultRenderersFactory` in
|
||||
`EXTENSION_RENDERER_MODE_ON`: the platform's decoders are tried first (a vendor DTS
|
||||
decoder stays in charge where there is one) and the FFmpeg audio renderer takes
|
||||
what they cannot decode. `CodecDetector` reports the extension's codecs beside the
|
||||
`MediaCodecList` ones, asking `FfmpegLibrary.supportsFormat` per MIME type rather
|
||||
than assuming, so a build whose native library failed to load reports only what
|
||||
the platform decodes. Rust's device profile and download policy read that list,
|
||||
which is what keeps "what we tell the server" and "what actually decodes" in step.
|
||||
|
||||
The decoder is `org.jellyfin.media3:media3-ffmpeg-decoder` — Jellyfin's build of
|
||||
media3's FFmpeg extension, versioned `<media3 version>+N`. **Bump it in the same
|
||||
commit as media3.** It is GPL-3.0: the distributed APK carries those terms, the
|
||||
source stays MIT (see `THIRD_PARTY_NOTICES.md`). Its JNI methods are covered by the
|
||||
AAR's own consumer rules and by `-keep class androidx.media3.** { *; }` in
|
||||
`proguard-jellytau.pro`, which also keeps the renderer ExoPlayer loads reflectively.
|
||||
|
||||
**Rejected:** re-encoding a download's audio on the device after it lands (a
|
||||
remux). It costs minutes of CPU and twice the disk per film, needs a pipeline
|
||||
state of its own, and does nothing for streaming. Decoding at playback fixes both
|
||||
paths with no extra step.
|
||||
|
||||
### The webview is not a video renderer on Android
|
||||
|
||||
ExoPlayer is Android's only video renderer. The HTML5 path used to be reachable
|
||||
through the `experimentalNativeVideo` setting (a *suppressor* of Rust's native
|
||||
choice), but the webview decodes none of the codecs above — so with the original
|
||||
file now downloaded as-is (DR-293), turning native video off would play every such
|
||||
download as a silent film. Rust reports `webview_video_fallback` in
|
||||
`PlaybackCapabilities`: **false on Android**, true only beside mpv native video on
|
||||
Linux, where the webview is still the tested fallback. The frontend offers the
|
||||
switch and honours a stored "off" only when it is true (`nativeVideoWanted` in
|
||||
`stores/nativeVideo.ts`), so a user who once switched it off on Android is not
|
||||
stranded on the silent path.
|
||||
|
||||
### The equalizer, and where its vocabulary lives
|
||||
|
||||
**TRACES**: UR-027 | DR-030, IR-020
|
||||
|
||||
+10
-3
@@ -494,6 +494,8 @@ Internal architecture, components, and application logic.
|
||||
| DR-290 | A download whose response states no length still reports progress against a predicted total. A transcode is produced as it is sent — chunked, no `Content-Length` — and the worker reported `progress: 0.0` for its whole duration: an empty bar reading "0%" while the byte count climbed for an hour, which is the case every film whose audio must be re-encoded lands in. The backend already fetches the item to decide the audio policy, and that item carries what a prediction needs: the source's size (an `original` download copies the picture, so the output is the source give or take the audio track — and exactly the source when nothing is re-encoded) and its runtime (a preset re-encodes at fixed rates, so the size is rate × runtime, from the same preset table the URL is built from so the two cannot drift). The prediction is made where the URL is resolved and persisted as the row's `file_size`; the worker uses it **only** when the response has no length, the server's figure always wins, an estimated bar is capped at 99% so a low prediction never shows a finished download still running, and the `Completed` event carries the bytes actually written so neither side persists the prediction as the real size. With no prediction the bar is indeterminate, which is honest and was the status quo. The single-video button joins the series/season buttons on the enqueue path so all three resolve — and predict — in one place | Downloads | UR-071 | Done |
|
||||
| DR-291 | The offline banner stays off the full-screen player. Every other shell rule in `layoutShell.ts` already treats `/player/*` as immersive; the amber "You're offline" strip was the one piece of chrome still rendered above it. On the native Android video path that is not cosmetic: VideoPlayer makes itself transparent so the ExoPlayer SurfaceView behind the WebView is visible (DR-185), so a shell child that still paints shows *through* the picture as a stripe across the top of the film. Offline is also precisely when a downloaded video plays, so the banner appeared when it was most in the way, and it offers the viewer nothing to act on — local playback needs no server. The rule moves into the pure module as `showOfflineBanner({ pathname, isAuthenticated, isConnected })` rather than staying an inline `{#if}` in the shell, so the immersive-route contract is stated in one tested place | UI | UR-003, UR-043 | Done |
|
||||
| DR-292 | The offline catalog reveal is one rule, applied by both library views. Two defects, one cause — "server only" was a private `$derived` inside `MediaCard`. (1) The list view (`LibraryListView`, what `LibraryGrid` renders when the stored view preference is `list`) had no notion of it at all, so a library browsed as a list offline showed every revealed item as an ordinary tappable row that plays nothing, with no way to queue it. (2) The rule asked the downloads store whether *this item id* was downloaded, but only a playable leaf (Audio, Movie, Episode) ever has a download row — an album's tracks carry them, the album does not — so a fully downloaded album greyed itself out and offered to queue what was already on the device, which is what "my downloaded music is greyed out" was. The rule moves to the pure `$lib/utils/serverOnly`, both views call it, and the container half is answered by the backend: `get_download_disk_usage().sizes` already carries container subtotals beside leaf sizes (DR-085), so `deviceContentIds` is membership in a Rust-computed map rather than a frontend guess at which item types are containers. That map was loaded only by the Downloads page, so the shell now primes it at startup and re-reads it whenever the offline gate settles (the DR-143 signal). Queueing is shared too (`queueOfflineDownload`), since the list view had no copy to diverge from | UI | UR-052, UR-055 | Done |
|
||||
| DR-293 | Android plays the original file: ExoPlayer decodes AC-3, E-AC-3, DTS and TrueHD in software through the FFmpeg extension, so neither a download nor a stream needs the server to re-encode its audio. These are licensed codecs that Android does not ship — the ROD2-W09 tablet has a vendor DTS decoder and no AC-3/E-AC-3 at all — so the download policy (DR-171) judged audio against the webview's list and turned most films into a server transcode: generated as it is sent, no `Content-Length`, `Range` ignored, measured at ~1 MB/s and restarting from zero on every network blip, against a direct copy that moved a 910 MB episode in 94 s. The renderer is `DefaultRenderersFactory` in `EXTENSION_RENDERER_MODE_ON` (platform decoders first, FFmpeg for what they lack), and `CodecDetector` reports the extension's codecs beside `MediaCodecList`'s, so the device profile and the download policy — now `renderer_can_decode_audio`, DR-234's per-platform answer, instead of the webview's list — agree with what actually decodes. The webview video path is gone on Android: it decodes none of those codecs, so an original-file download would play there as a silent film; `webview_video_fallback` (Rust) is false on Android and the frontend neither offers the switch nor honours a stored "off". Linux keeps the webview fallback beside mpv native video, and with it the server transcode for undecodable audio. Rejected: re-encoding audio on the device after download — minutes of CPU and twice the disk per film, and it would not have helped streaming. The decoder is Jellyfin's `media3-ffmpeg-decoder` build (GPL-3.0; the distributed APK carries its terms, the source stays MIT) and must be versioned in step with media3 | Playback | UR-004, UR-071 | Done |
|
||||
| DR-294 | A download plays with no network. Playing a downloaded item asked the server for its `PlaybackInfo` — only to read the media-source id that subtitle URLs are keyed by — and `HybridRepository::get_playback_info` went to the server alone, so offline the call retried for seven seconds, failed, and the file on disk was never opened. A completed download for the current user now answers playback info from its download row, first and regardless of reachability: the local path, direct play, and the item id as media-source id (a download names no source, so the server served its default, which carries the item's id). Next Up had the same shape — server-only — and the TV landing page loads it in one `Promise.all` with its other rows, so offline that single failure blanked the whole page with Continue Watching and Latest sitting in the cache; it now falls back to the cache when the server cannot answer. And a slow cache read is waited for, never discarded: the cache is one SQLite connection behind one mutex, so any write in progress (the catalog sync at every launch, a download finishing) pushes a read past the 100 ms fast path, and `get_items`, the library list, genres and playlist items discarded such a read, waited on the server, and offline returned its error over data on disk — "More info" on a downloaded show failed exactly so. They keep the read running (`cache_try`) and wait for it when the server fails (`settle`); the cache-only reads (search, favourites) simply await the cache | Repository | UR-002, UR-071 | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -504,9 +506,9 @@ Internal architecture, components, and application logic.
|
||||
| User Req | Integration Requirements | Development Requirements |
|
||||
|----------|-------------------------|-------------------------|
|
||||
| UR-001 | IR-001, IR-002 | - |
|
||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014, DR-294 |
|
||||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196, DR-291 |
|
||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265 |
|
||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265, DR-293 |
|
||||
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
|
||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262, DR-277, DR-278 |
|
||||
@@ -572,7 +574,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-068 | - | DR-119 |
|
||||
| UR-069 | - | DR-113, DR-114, DR-120 |
|
||||
| UR-070 | - | DR-121, DR-122 |
|
||||
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180, DR-198, DR-199, DR-289, DR-290 |
|
||||
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180, DR-198, DR-199, DR-289, DR-290, DR-293, DR-294 |
|
||||
| UR-072 | - | DR-156 |
|
||||
| UR-073 | - | DR-158 |
|
||||
| UR-074 | - | DR-162, DR-177, DR-181 |
|
||||
@@ -847,6 +849,11 @@ Internal architecture, components, and application logic.
|
||||
| UT-255 | The offline banner shows while offline on ordinary routes and never on `/player/*`, and stays off while connected or signed out | DR-291 | Done |
|
||||
| UT-257 | The server-only rule: true only offline with the reveal on and nothing on the device; never for a library tile; and not for a container whose children are downloaded (the greyed-album regression) | DR-292 | Done |
|
||||
| UT-258 | The list view greys a server-only row, makes it inert to tap, offers the queue button (and the Queued badge once pending), and leaves downloaded rows and containers with device content alone | DR-292 | Done |
|
||||
| UT-259 | The user may send video to the webview only beside mpv native video on Linux: never on Android, where ExoPlayer is the only video renderer, and not where the webview is the only renderer | DR-293 | Done |
|
||||
| UT-260 | A downloaded item gets playback info with the server unreachable — immediately, from its download row (local path, direct play, item id as media source) — while an unfinished download, another user's, or an item never downloaded is left to the server | DR-294 | Done |
|
||||
| UT-261 | Next Up answers from the cache, rather than failing, when the server is unreachable | DR-294 | Done |
|
||||
| UT-262 | The Android webview fallback is neither offered in Settings nor honoured by the player unless Rust reports it, so a stored "native video off" cannot route video to a renderer that plays the original file silent | DR-293 | Done |
|
||||
| UT-263 | With the database held past the 100 ms fast path and the server unreachable, `get_items`, the library list, a cache-only search and cache-only favourites all answer from the cache instead of failing | DR-294 | Done |
|
||||
### Integration Tests
|
||||
|
||||
| Test ID | Test Description | Traces To | Status |
|
||||
|
||||
@@ -28,8 +28,10 @@ know how something *works*, read
|
||||
|
||||
**Next free requirement ids** (always re-check
|
||||
[requirements.md](../requirements.md) before allocating): **UR-086**,
|
||||
**IR-036**, **JA-038**, **DR-291** (DR-289/290 went to the download stall
|
||||
timeout and the transcode progress estimate in v0.12.2). Three specs below suggested ids that have
|
||||
**IR-036**, **JA-038**, **DR-295**, **UT-264** (DR-289/290 went to the v0.12.2
|
||||
download fixes; DR-291/292 and UT-255/257/258 to the offline-banner and
|
||||
server-only-reveal work; DR-293/294 and UT-259-263 to Android's FFmpeg decoding
|
||||
and offline-without-network). Three specs below suggested ids that have
|
||||
since been taken by other work; each carries a ⚠️ note at the top — this line
|
||||
was itself stale by five, two and forty-seven until 2026-09-08, which is why the
|
||||
re-check is not optional.
|
||||
@@ -50,7 +52,6 @@ re-check is not optional.
|
||||
|---|---|
|
||||
| [desktop-native-video.md](desktop-native-video.md) | mpv draws video on every desktop platform, then the webview `<video>` path and hls.js are deleted. Converts a measured 7% direct-play rate toward Android's 85%. Stacked on backend-owned stream selection. |
|
||||
| [backend-owned-stream-selection.md](backend-owned-stream-selection.md) | Rust owns direct-play-vs-transcode, transport and quality; players consume one `StreamSelection`. Partly built — `StreamSelection`, `Transport` and the `.m3u8` sniff removal have landed. |
|
||||
| [on-device-audio-remux.md](on-device-audio-remux.md) | Downloads fetch the original (`Static=true`, resumable — measured 14× faster) and a bundled FFmpeg re-encodes only the audio on device, replacing DR-171's server-side transcode. HEVC side effect accepted (Android-first; the webview video path is being retired). Android APK size increase still to be measured. |
|
||||
| [build-provenance.md](build-provenance.md) | `build.rs` is still bare. ⚠️ suggested id DR-093 is taken. |
|
||||
| [player-facade-enforcement.md](player-facade-enforcement.md) | ~60 `commands.player*` sites still outside the facade; no lint rule. ⚠️ suggested id DR-095 is taken. |
|
||||
| [windows-native-audio-backend.md](windows-native-audio-backend.md) | Blocked on the libmpv2 swap. ⚠️ suggested id IR-030 is taken. |
|
||||
@@ -84,4 +85,5 @@ Where to look for each:
|
||||
| Video background audio | [05-platform-backends.md](../architecture/05-platform-backends.md) — Background Audio Handoff |
|
||||
| Traceability gate repair | [traceability-ci.md](../traceability-ci.md) |
|
||||
| Boundary tripwire hardening | `scripts/check-frontend-boundary.sh` (its header is the spec) |
|
||||
| Original-file downloads & Android FFmpeg decoding (was on-device-audio-remux) | [05-platform-backends.md](../architecture/05-platform-backends.md) — Licensed audio codecs; [06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md) — What a Video Download Fetches, Offline Means No Network |
|
||||
| Playback docs corrections · req-coverage script removal | Nothing to document — both were corrections that have been applied |
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
# 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 <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:
|
||||
|
||||
```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 `<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](../requirements.md) (DR-290 is taken by
|
||||
the progress-estimate work) and **UT-255**–**UT-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/`.
|
||||
Reference in New Issue
Block a user