Compare commits

...
10 Commits
Author SHA1 Message Date
dtourolle 9dd44eeada chore(release): v0.13.0
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 1m23s
📱 Test APK / Build test APK (push) Successful in 58m53s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 10m51s
Traceability Validation / Check Requirement Traces (push) Successful in 29s
Build & Release / Run Tests (push) Successful in 10m38s
Build & Release / Build Linux (push) Successful in 14m27s
Build & Release / Build Windows (push) Successful in 16m56s
Build & Release / Build Android (push) Successful in 20m4s
Build & Release / Create Release (push) Successful in 31s
Android plays and downloads the original file — Dolby and DTS audio decode
on the device — and offline mode no longer needs the network.
2026-09-22 22:23:00 -04:00
dtourolle 5259b47cf3 fix(offline): offline mode no longer needs the network
Three defects made "offline" depend on a server it could not reach.

A downloaded film would not play offline. The player found the file on disk,
then asked the server for the item's PlaybackInfo only to read its
media-source id; with no network that retried for seven seconds and failed,
and the file was never opened. A completed download now answers playback
info from its download row — local path, direct play, item id as media
source — and the hybrid repository consults it before the network.

"More info" on a downloaded show failed with "Failed to load item". 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 returned its error
over data sitting on disk. They now keep the read running and wait for it
when the server fails; the cache-only reads (search, favourites) simply
await the cache, having no server to fall back from.

Next Up went only to the server, and the TV landing page loads it in one
Promise.all with its other rows, so offline it blanked the whole page. It now
falls back to the cache.

Each fix has a test that failed first against an unreachable server (and, for
the cache, a database held past the fast path).

DR-294, UT-260, UT-261, UT-263.
2026-09-22 22:22:14 -04:00
dtourolle bed1030443 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.
2026-09-22 22:22:05 -04:00
dtourolle bb7d5dc01a fix(offline): one server-only rule for both library views
Two defects with one cause: "server only" was a private $derived inside
MediaCard.

The list view (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.

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

The rule moves to the pure $lib/utils/serverOnly and both views call it.
The container half is answered by the backend rather than guessed at:
get_download_disk_usage().sizes already carries container subtotals
beside leaf sizes (DR-085), so deviceContentIds is membership in a
Rust-computed map, not a frontend list of which item types are
containers. That map was loaded only by the Downloads page, so the shell
primes it at startup and re-reads it whenever the offline gate settles.
Queueing is shared too, since the list view had no copy to diverge from.

TRACES: UR-052, UR-055 | DR-292 | UT-257, UT-258
2026-09-22 21:29:38 -04:00
dtourolle a90de67c54 fix(ui): stack episode title under the thumbnail on narrow rows
A fixed 160px thumbnail beside the title left phone-width rows cramped.
A container query stacks a full-width thumbnail above the info block when
the row is under 28rem, lets the title wrap to two lines there, and
requests a 640px image so the wider thumbnail stays sharp.
2026-09-22 21:27:38 -04:00
dtourolle b98cbfe28a fix(offline): keep the offline banner 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, since local playback needs no server.

The rule moves into the pure module as showOfflineBanner() rather than
staying an inline {#if} in the shell, so the immersive-route contract is
stated in one tested place.

TRACES: UR-003, UR-043 | DR-291 | UT-255
2026-09-22 21:08:03 -04:00
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
dtourolleandClaude Opus 5 ff21c4cd44 chore(release): v0.12.2
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 27s
📱 Test APK / Build test APK (push) Successful in 32m30s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m56s
Traceability Validation / Check Requirement Traces (push) Successful in 13s
Build & Release / Run Tests (push) Successful in 16m28s
Build & Release / Build Linux (push) Successful in 21m48s
Build & Release / Build Windows (push) Successful in 19m42s
Build & Release / Build Android (push) Successful in 32m19s
Build & Release / Create Release (push) Successful in 33s
Two download fixes since v0.12.1: transfers longer than five minutes were
being cut off by a total request deadline and restarted from zero, and the
progress bar read "0%" for the whole of a transcode download.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:19:32 +02:00
dtourolleandClaude Opus 5 b3228cb4f4 feat(downloads): estimate a transcode's size so the progress bar moves
A transcode is produced as it is sent — chunked, with 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. That 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 its runtime (a preset re-encodes at fixed rates, so the
size is rate × runtime — from a preset table the URL builder now shares, 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 now carries the bytes actually written so the
frontend stops persisting the row's file_size as the final size.

The row renders three honest states: exact "42%", estimated "~42%" with
"X / ~Y", or — with no total at all — an indeterminate band and the bytes
so far, never "0%". The single-video button joins the series/season buttons
on the enqueue path so all three resolve, and predict, in one place.

DR-290, UT-252, UT-253, UT-254.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:18:29 +02:00
dtourolleandClaude Opus 5 e271874b1d fix(downloads): replace the 5-minute total request deadline with a stall timeout
The download worker built its HTTP client with `Client::timeout(300s)`, which
in reqwest is a total deadline that runs until the response body has finished.
Every transfer longer than five minutes was cut off mid-body as "error
decoding response body" and retried. A transcode ignores `Range`, so each
retry restarted from byte zero, met the same deadline, and after three
attempts the download failed — no feature film at transcode speed ever
completed on a device whose audio must be re-encoded, and a large direct copy
limped through in five-minute slices with a backoff between each.

A connect timeout plus a read timeout that resets on every chunk catches a
dead connection without capping how long a healthy transfer may run.

Red first: a loopback server dribbling a body three times longer than the
timeout failed with the old client (and burned the whole retry budget) and
passes now; a second test hangs the socket and shows the stall is still
detected.

DR-289, UT-251.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:18:20 +02:00
54 changed files with 9624 additions and 5080 deletions
+75
View File
@@ -9,6 +9,81 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
For how long each fixed defect had been shipping before it was found, see
[docs/defect-windows.md](docs/defect-windows.md).
## v0.13.0
Android plays and downloads the original file, and offline mode works without
a network. Found on a tablet with no Dolby decoder, where nearly every film was
being transcoded by the server — slowly, and unresumably — just for its audio.
### ✨ Features
- **Dolby and DTS audio play on every Android device.** Android ships no AC-3,
E-AC-3, DTS or TrueHD decoders — they exist only where a manufacturer paid for
them. The player now decodes them itself (FFmpeg), so these films stream and
download as the original file instead of a server transcode: direct play when
streaming, and a download that runs at full speed, shows a real percentage,
and resumes after a dropped connection. Measured: a 910 MB E-AC-3 5.1 episode
in 94 seconds, where a transcode managed about 1 MB/s. (DR-293)
### 🐛 Fixes
- **Downloaded films play offline.** Playing a download asked the server for
details it did not need, so with no network it waited seven seconds, failed,
and never opened the file on disk. It now answers from the download itself.
(DR-294)
- **"Failed to load item" offline.** Opening a downloaded show's details could
fail while the app was writing to its database (the catalog sync at every
launch, a download finishing): the cached answer was thrown away for being
slow, and the server — unreachable — was reported instead. The library list,
genres, playlists, search and favourites had the same flaw. They now wait for
the cache. (DR-294)
- **The TV page no longer blanks offline.** Its "Next Up" row was server-only,
and its failure took the whole page with it. It now falls back to the cache.
(DR-294)
### 🔧 Changes
- **Native video is no longer optional on Android.** The built-in web player
cannot decode Dolby or DTS audio, so with original files downloaded it would
play them silent. The "Native Video" switch is gone from Android settings (it
remains on Linux, beside mpv native video).
- **Licence.** The Android app now bundles a GPL-3.0 component (the FFmpeg audio
decoder), so the distributed APK carries GPL-3.0 terms; JellyTau's source stays
MIT. See `THIRD_PARTY_NOTICES.md`.
## v0.12.2
Two download fixes, found together on a tablet whose films all needed their
audio re-encoded. Downloads that took longer than five minutes were being cut
off and restarted, and the progress bar sat on "0%" for the whole of a
transcode.
### 🐛 Fixes
- **Downloads longer than five minutes no longer fail.** The download worker's
HTTP client carried a *total* request deadline of five minutes — from
connect until the last byte — so every transfer longer than that was cut off
mid-body and retried. A transcode cannot be resumed (the server ignores
`Range` and starts over), so each retry threw away what had been fetched, hit
the same deadline, and after three attempts the download failed. No feature
film at transcode speed could complete; a large direct copy limped through in
five-minute slices with a backoff between each. The deadline is now a
30-second connect timeout plus a 60-second *stall* timeout that resets on
every chunk: a dead connection is still caught, a healthy transfer can run as
long as it needs. Present since the first release. (DR-289)
### ✨ Improvements
- **The progress bar moves during a transcode download.** A transcode is
produced as it is sent, with no `Content-Length`, and the bar had nothing to
measure against — it read "0%" until the file completed. The backend now
predicts the size when it resolves the download: the source's size for
`original` (the picture is copied byte-for-byte; only the audio changes), or
bitrate × runtime for a quality preset. The bar shows the estimate as
"~42%" and caps at 99% until the last byte lands; the server's own figure is
used whenever it gives one; and when there is no prediction at all the bar
is a moving band with the bytes so far, rather than a false "0%". (DR-290)
## v0.12.1
One change: the TLS library every connection to the server goes through has a
+21
View File
@@ -0,0 +1,21 @@
# Third-party notices
JellyTau's own source code is licensed under the MIT License (see `LICENSE`).
Some builds bundle third-party components under other licences, listed here.
## Android: FFmpeg audio decoder (GPL-3.0)
The Android app bundles **`org.jellyfin.media3:media3-ffmpeg-decoder`**, the
Jellyfin project's build of the media3 FFmpeg extension, which contains FFmpeg.
It lets the player decode AC-3, E-AC-3, DTS and TrueHD audio, which Android does
not ship.
- Licence: **GNU General Public License v3.0**
- Source: <https://github.com/jellyfin/jellyfin-androidx-media> (build of
<https://github.com/androidx/media>), with FFmpeg from <https://ffmpeg.org>
Because this component is GPL-3.0, **the Android APK as distributed is subject
to the terms of the GPL-3.0**. The complete corresponding source for JellyTau is
available in this repository; JellyTau's own code remains available under MIT.
Desktop builds do not include this component.
+47 -2
View File
@@ -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
@@ -186,6 +186,62 @@ Per-item disk usage comes from `repository_get_download_disk_usage`
Downloaded browse cards, detail pages, the device total and the remove
confirmation (DR-085).
## What a Video Download Fetches
**TRACES**: UR-071, UR-004 | DR-171, DR-293
An `original`-quality download is the server's untouched file (`Static=true`)
unless its audio cannot be decoded by **the renderer that will play it**
`renderer_can_decode_audio`, DR-234's per-platform answer. Only then is the
server asked to re-encode the audio on the way down (`allowVideoStreamCopy`
keeps the picture byte-for-byte).
The distinction matters because a transcode is generated as it is sent: no
`Content-Length`, `Range` ignored. It measured ~1 MB/s and restarted from byte
zero on every network blip, against a direct copy that moved a 910 MB episode in
94 s with no retries. On Android the renderer is ExoPlayer with the FFmpeg
extension ([05-platform-backends.md](05-platform-backends.md)), which decodes
AC-3/E-AC-3/DTS/TrueHD, so Android downloads are always the direct copy. On
Linux the webview still renders video and the transcode still applies.
The policy used to judge against the *webview's* codec list on every platform
(DR-171), because a download outlives the native-video setting that was active
when it arrived. That reasoning is why Android's webview video path was removed
rather than merely defaulted off: a file downloaded as the original must never
meet a renderer that cannot decode it.
## Offline Means No Network
**TRACES**: UR-002, UR-071 | DR-294
Three defects made "offline" depend on the network; the invariants that replace
them:
- **A download plays without the server.** Playing a downloaded item asked the
server for its `PlaybackInfo` only to read the media-source id; offline that
retried for seven seconds, failed, and the file was never opened.
`OfflineRepository::local_playback_info` answers for any completed download of
the current user — local path, direct play, item id as media source (a download
names no source, so the server served its default, which carries the item's id)
— and `HybridRepository::get_playback_info` consults it **first**.
- **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 that
starts at every launch, a download finishing) pushes a read past the 100 ms fast
path. `get_items`, the library list, genres and playlist items used to discard
such a read, wait for the server, and — offline — return its error over data on
disk; "More info" on a downloaded show failed that way. They now start the read
with `cache_try` (which keeps it running) and `settle` on it when the server
fails. Cache-only reads (search, favourites) have no server to fall back from,
so they simply await the cache.
- **Server-only sections degrade, they do not fail a page.** Next Up went only to
the server, and the TV landing page loads it in one `Promise.all`, so offline it
blanked the whole page. It now falls back to the cache when the server cannot
answer.
What still needs the server, deliberately: streaming anything not downloaded,
live TV and channels, reporting playback, and edits (favourites, playlists,
played state).
## Download Commands
**Location**: `src-tauri/src/commands/download/``mod.rs` (the commands below), `pinning.rs`, `smart_cache.rs`
+10 -2
View File
@@ -17,8 +17,8 @@ row can be re-checked or disputed:
## Present since the first release
Sixteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
shipped for between two weeks and two months before anyone hit them.
Eighteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
shipped for between two weeks and three months before anyone hit them.
That is the dominant pattern here: not regressions, but original assumptions that
went unexercised until a later feature leaned on them.
@@ -47,6 +47,14 @@ for ~2.
| Audio-track change asked the player to select a track the transcode never carried (DR-258) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
| Subtitle URL missing its `Stream.` route segment, so every fetch 404ed (DR-259) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
| `timeupdate` gated on `!isPlaying`, so a paused activity froze the position (DR-265) | v0.0.1 | **v0.11.5** | ~9 weeks | pickaxe |
| Download client used a 5-minute *total* request deadline, so any transfer longer than that was cut off and retried (DR-289) | v0.0.1 | **v0.12.2** | ~13 weeks | pickaxe |
| Progress reported 0.0 for any response without `Content-Length` — every transcode download (DR-290) | v0.0.1 | **v0.12.2** | ~13 weeks | absence |
The two v0.12.2 rows are the same latent shape as the `Range` header above:
the deadline was inert while every download was a short direct copy, and
became fatal only once DR-171 (v0.5.3) started re-encoding audio for offline
playback — a transcode is both slow enough to exceed five minutes and
impossible to resume. Defective for ~13 weeks, hittable for ~5.
### Why they took so long to surface
+25 -7
View File
@@ -490,6 +490,12 @@ Internal architecture, components, and application logic.
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
| DR-287 | Authentication uses only the spellings Jellyfin 12.0 leaves enabled. 12.0 disables `X-Emby-Authorization`, `X-Emby-Token`, `X-MediaBrowser-Token`, the `Emby` scheme and the `api_key` **query parameter** by default, and a migration (`DisableLegacyAuthorization`) turns them off on upgraded servers too — so a client using them stops working against an upgraded server rather than degrading. This is not a version branch: `Authorization` with the `MediaBrowser` scheme, and `ApiKey` as a query parameter, are ungated on *both* generations, and the header value this app already built was always the correct one. So the fix is a rename at 21 header sites and 28 query sites, not a capability flag. The query-parameter spelling is load-bearing rather than cosmetic: stream URLs are handed to mpv, ExoPlayer and the webview's `<video>`, none of which can set a header, so `ApiKey` is the only way a player authenticates at all. A structural test refuses any deprecated spelling reaching a request builder, because the failure is silent until a server upgrades | Security | UR-085 | Proposed |
| DR-288 | A type-filtered listing states `Recursive` explicitly. Jellyfin 12.0 defaults it to true when the parent is a library folder and `IncludeItemTypes` is set, where 10.11 returned immediate children — the identical request, a different result set, with nothing in the response to say which rule applied. Sending the value the client actually wants makes both generations agree, and the value sent is the one that shipped rather than the new server-side default, so this is a compatibility fix and not a silent behaviour change | Repository | UR-085 | Proposed |
| DR-289 | The download worker's HTTP client carries a **read** timeout and a connect timeout, never a total request timeout. reqwest's `Client::timeout` is a deadline that runs until the body has finished, and it was set to five minutes: every transfer longer than that was cut off mid-body as "error decoding response body" and retried. A transcode ignores `Range`, so each retry restarted from byte zero, met the same deadline, and after three attempts the download failed — no feature film at transcode speed ever completed on a device whose audio must be re-encoded, and a large direct copy limped through in five-minute slices with a backoff between each. A read timeout resets on every chunk, so it still catches a dead connection without capping how long a healthy transfer may run | Downloads | UR-071 | Done |
| 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 |
---
@@ -500,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-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 |
| 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-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, 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 |
@@ -541,7 +547,7 @@ Internal architecture, components, and application logic.
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263, DR-266 |
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188, DR-265, DR-266 |
| UR-042 | IR-009, IR-014 | DR-054 |
| UR-043 | IR-027 | DR-055 |
| UR-043 | IR-027 | DR-055, DR-291 |
| UR-044 | - | DR-056 |
| UR-045 | - | DR-057 |
| UR-046 | IR-028 | DR-058 |
@@ -550,10 +556,10 @@ Internal architecture, components, and application logic.
| UR-049 | IR-010 | DR-063, DR-064, DR-065, DR-147 |
| UR-050 | - | DR-066, DR-067 |
| UR-051 | - | DR-068, DR-069, DR-070 |
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143, DR-292 |
| UR-053 | IR-029 | DR-074 |
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173 |
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173, DR-292 |
| UR-056 | - | DR-085 |
| UR-057 | - | DR-086 |
| UR-058 | - | DR-087, DR-142 |
@@ -568,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 |
| 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 |
@@ -836,6 +842,18 @@ Internal architecture, components, and application logic.
| UT-248 | Narrowing the library clause does not starve the libraries that do have landing pages: music, movies and TV each still list their own media and none of the others | DR-277 | Done |
| UT-249 | Opening an individual collection still lists its own children: a BoxSet's members are matched by the stored `parent_id`, not by the library clause, so narrowing that clause did not empty collections | DR-277 | Done |
| UT-250 | Two libraries of the same collection type are not interchangeable: seeded through the real cache write path, a "TV" and a "Shows" library each list their own series and not the other's | DR-278 | Done |
| UT-251 | A download that runs longer than the stall timeout completes as long as bytes keep arriving, and one whose connection goes silent is given up on promptly as a network error — driven through the real `reqwest` client against a loopback socket, because the defect was the client's configuration | DR-289 | Done |
| UT-252 | A predicted total fills in only when the server sent no length, the server's length always wins, an estimated fraction is capped below 1.0, and the prediction is rate × runtime for a preset and the source's size for `original` | DR-290 | Done |
| UT-253 | Resolving a queued video row persists its predicted size, and resolving an audio row (no prediction) leaves a size the row already holds untouched | DR-290 | Done |
| UT-254 | The progress row renders an unknown total as indeterminate rather than "0%", an estimated total as "~N%", an exact one plainly; the store carries the estimate flag through progress and persists the worker's byte count, never the prediction, on completion | DR-290 | Done |
| 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 |
+5 -1
View File
@@ -28,7 +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-289**. 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.
@@ -82,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 |
+7135 -4684
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.12.1",
"version": "0.13.0",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT",
+1 -1
View File
@@ -2275,7 +2275,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.12.1"
version = "0.13.0"
dependencies = [
"aes-gcm",
"argon2",
+4 -1
View File
@@ -4,7 +4,7 @@ name = "jellytau"
# `player-conformance`, and a second binary makes a bare `cargo run` —
# which `tauri dev` issues — ambiguous.
default-run = "jellytau"
version = "0.12.1"
version = "0.13.0"
description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
license = "MIT"
@@ -150,6 +150,9 @@ ndk-context = "0.1"
[dev-dependencies]
tempfile = "3.24.0"
# `net` for the loopback server in `download::worker::timeout_tests`; reqwest
# enables it transitively, but a test must not depend on that.
tokio = { version = "1", features = ["net"] }
wiremock = "0.6.5"
[features]
+10
View File
@@ -172,6 +172,16 @@ dependencies {
// itself: without a view to hand them to, a selected subtitle track renders
// nowhere. See JellyTauPlayer.onCues. (DR-260)
implementation("androidx.media3:media3-ui:1.5.0")
// Software audio decoders for what Android does not ship: AC-3, E-AC-3,
// DTS and TrueHD are licensed codecs, present only where a vendor paid for
// them (the ROD2-W09 tablet has DTS but no AC-3/E-AC-3 at all). With this,
// ExoPlayer plays the source file as-is, so neither a download nor a stream
// needs the server to re-encode its audio (DR-293). Jellyfin's own build of
// the media3 FFmpeg extension, versioned to match media3 above — keep the
// two in step. Licence: GPL-3.0 — the distributed APK carries its terms,
// the source stays MIT; see THIRD_PARTY_NOTICES.md and
// docs/architecture/05-platform-backends.md.
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.5.0+1")
implementation("com.google.guava:guava:33.0.0-android")
// Media library for VolumeProviderCompat (remote volume control)
@@ -3,8 +3,11 @@ package com.dtourolle.jellytau.player
import android.content.Context
import android.media.MediaCodecList
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.decoder.ffmpeg.FfmpegLibrary
import androidx.media3.exoplayer.audio.AudioCapabilities
/**
@@ -13,9 +16,24 @@ import androidx.media3.exoplayer.audio.AudioCapabilities
* This class queries the device's media codec capabilities and reports
* them to the Rust backend via JNI for accurate DeviceProfile generation.
*/
@OptIn(UnstableApi::class) // FfmpegLibrary and the licensed-codec MimeTypes
object CodecDetector {
private const val TAG = "CodecDetector"
/**
* Formats the FFmpeg extension can decode, as Jellyfin codec names. The
* platform decodes the rest itself; these are the licensed codecs a device
* often lacks.
*/
private val FFMPEG_AUDIO_FORMATS = listOf(
MimeTypes.AUDIO_AC3 to "ac3",
MimeTypes.AUDIO_E_AC3 to "eac3",
MimeTypes.AUDIO_E_AC3_JOC to "eac3",
MimeTypes.AUDIO_DTS to "dts",
MimeTypes.AUDIO_DTS_HD to "dts",
MimeTypes.AUDIO_TRUEHD to "truehd",
)
/**
* Data class to hold detected codec capabilities.
*/
@@ -67,6 +85,25 @@ object CodecDetector {
}
}
// The FFmpeg extension decodes in software what the platform lacks.
// ExoPlayer uses it for playback (JellyTauPlayer's renderers factory),
// so it belongs in the same list: Rust judges both the streaming
// profile and the download policy against this set, and a codec
// missing here is re-encoded by the server for nothing. Asked per
// format rather than assumed, so a build whose native library failed
// to load reports only what the platform itself decodes.
// TRACES: UR-004, UR-071 | DR-293
if (FfmpegLibrary.isAvailable()) {
for ((mime, codec) in FFMPEG_AUDIO_FORMATS) {
if (FfmpegLibrary.supportsFormat(mime)) {
audioCodecs.add(codec)
Log.d(TAG, "Audio codec: $codec (MIME: $mime, FFmpeg extension)")
}
}
} else {
Log.w(TAG, "FFmpeg extension unavailable; reporting platform decoders only")
}
Log.i(TAG, "Detected ${videoCodecs.size} video codecs: ${videoCodecs.sorted()}")
Log.i(TAG, "Detected ${audioCodecs.size} audio codecs: ${audioCodecs.sorted()}")
} catch (e: Exception) {
@@ -148,7 +185,12 @@ object CodecDetector {
"audio/eac3" -> "eac3"
"audio/eac3-joc" -> "eac3"
"audio/dts" -> "dts"
// The platform's own spelling — what MediaCodecList reports on the
// ROD2-W09. Only the `.hd` variant was listed, so plain DTS was
// detected by luck, through the HD decoder advertising both.
"audio/vnd.dts" -> "dts"
"audio/vnd.dts.hd" -> "dts"
"audio/true-hd" -> "truehd"
"audio/x-ms-wma" -> "wma"
"audio/amr-nb" -> "amrnb"
"audio/amr-wb" -> "amrwb"
@@ -19,6 +19,7 @@ import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
@@ -332,6 +333,18 @@ class JellyTauPlayer(private val appContext: Context) {
//
// TRACES: UR-004, UR-006 | IR-008
exoPlayer = ExoPlayer.Builder(appContext)
// Extension renderers ON: the device's own decoders are tried first
// (a vendor DTS decoder stays in charge where there is one), and the
// FFmpeg audio renderer takes any format they cannot decode — AC-3,
// E-AC-3, TrueHD on a device without Dolby licensing. This is what
// lets the untouched source file play, instead of a server transcode.
// CodecDetector reports the same codecs to Rust, so the device
// profile and the download policy agree with what actually decodes.
// TRACES: UR-004, UR-071 | DR-293
.setRenderersFactory(
DefaultRenderersFactory(appContext)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
)
// Decline the player's own load-error retry for a stream it could
// only restart (DR-203). Every other source keeps the default
// behaviour, which resumes the failed load where it stopped.
+99 -16
View File
@@ -517,6 +517,34 @@ pub(crate) async fn requeue_mistyped_video_downloads(
Ok(n)
}
/// What a resolver hands back for one queued row: the URL to fetch and, for a
/// video, the size predicted for it (see `download::estimate`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedDownloadUrl {
pub url: String,
pub expected_bytes: Option<u64>,
}
impl From<String> for ResolvedDownloadUrl {
/// An audio stream URL: served static, so the response states its own
/// length and nothing needs predicting.
fn from(url: String) -> Self {
Self {
url,
expected_bytes: None,
}
}
}
impl From<crate::repository::ResolvedVideoDownload> for ResolvedDownloadUrl {
fn from(r: crate::repository::ResolvedVideoDownload) -> Self {
Self {
url: r.url,
expected_bytes: r.expected_bytes,
}
}
}
/// Core of [`resume_queued_downloads`], factored out for testing: select every
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
/// `None` leaves the row pending), and heal the row so the pump can start it.
@@ -534,7 +562,7 @@ pub(crate) async fn resolve_pending_download_urls<F, Fut>(
) -> Result<ResumeQueuedResult, String>
where
F: Fn(String, String, String) -> Fut,
Fut: std::future::Future<Output = Option<String>>,
Fut: std::future::Future<Output = Option<ResolvedDownloadUrl>>,
{
if only_ids.is_some_and(|ids| ids.is_empty()) {
return Ok(ResumeQueuedResult {
@@ -600,8 +628,8 @@ where
let mut failed = 0usize;
for (download_id, item_id, media_type, quality) in rows {
let stream_url = match resolve(item_id.clone(), media_type, quality).await {
Some(url) => url,
let target = match resolve(item_id.clone(), media_type, quality).await {
Some(target) => target,
None => {
failed += 1;
continue;
@@ -609,13 +637,21 @@ where
};
// Heal the row so the pump can start it. Guard on stream_url IS NULL so a
// concurrent resolver doesn't clobber an already-started row.
// concurrent resolver doesn't clobber an already-started row. The
// predicted size, when there is one, gives the worker a progress total
// for a response that carries none (DR-290).
let expected = target
.expected_bytes
.and_then(|n| i64::try_from(n).ok())
.map_or(QueryParam::Null, QueryParam::Int64);
let update = Query::with_params(
"UPDATE downloads SET stream_url = ?, target_dir = ?
"UPDATE downloads SET stream_url = ?, target_dir = ?,
file_size = COALESCE(?, file_size)
WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
vec![
QueryParam::String(stream_url),
QueryParam::String(target.url),
QueryParam::String(target_dir.to_string()),
expected,
QueryParam::Int64(download_id),
],
);
@@ -705,17 +741,18 @@ pub async fn resume_queued_downloads(
async move {
if media_type == "video" {
Some(
crate::repository::resolve_video_download_url(
crate::repository::resolve_video_download(
repo.as_ref(),
&item_id,
&quality,
None,
)
.await,
.await
.into(),
)
} else {
match repo.get_audio_stream_url(&item_id).await {
Ok(url) => Some(url),
Ok(url) => Some(url.into()),
Err(e) => {
warn!(
"[Catalog] Failed to resolve audio URL for {}: {:?}",
@@ -807,6 +844,7 @@ mod tests {
target_dir TEXT,
media_type TEXT,
quality_preset TEXT,
file_size INTEGER,
progress REAL DEFAULT 0,
bytes_downloaded INTEGER DEFAULT 0,
started_at TEXT,
@@ -890,7 +928,7 @@ mod tests {
&db,
"/data/downloads",
None,
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}").into()) },
)
.await
.unwrap();
@@ -932,7 +970,7 @@ mod tests {
&db,
"/data",
Some(&[mine]),
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}").into()) },
)
.await
.unwrap();
@@ -962,7 +1000,7 @@ mod tests {
let out =
resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
Some(format!("http://resolved/{item_id}"))
Some(format!("http://resolved/{item_id}").into())
})
.await
.unwrap();
@@ -1015,7 +1053,7 @@ mod tests {
let seen = Arc::clone(&seen_c);
async move {
seen.lock_safe().push((item_id.clone(), media_type));
Some(format!("http://resolved/{item_id}"))
Some(format!("http://resolved/{item_id}").into())
}
})
.await
@@ -1048,7 +1086,7 @@ mod tests {
let seen = Arc::clone(&seen_c);
async move {
*seen.lock_safe() = media_type;
Some("http://x".to_string())
Some("http://x".to_string().into())
}
})
.await
@@ -1072,7 +1110,7 @@ mod tests {
let seen = Arc::clone(&seen_c);
async move {
*seen.lock_safe() = media_type;
Some("http://x".to_string())
Some("http://x".to_string().into())
}
})
.await
@@ -1123,6 +1161,51 @@ mod tests {
assert_eq!(status, "completed", "a correct video download is untouched");
}
/// A transcode answers with no `Content-Length`, so the worker's only
/// chance at a progress total is the size predicted at resolve time. That
/// prediction has to reach the row, and only where there is one — an
/// audio row's `None` must not null out a size the row already holds.
///
/// TRACES: UR-071 | DR-290 | UT-253
#[tokio::test]
async fn resolving_persists_the_predicted_size_without_erasing_a_known_one() {
let db = test_db();
insert_download(&db, "film", "pending", None, Some("video")).await;
insert_download(&db, "track", "pending", None, Some("audio")).await;
db.execute(Query::with_params(
"UPDATE downloads SET file_size = 777 WHERE item_id = ?",
vec![QueryParam::String("track".to_string())],
))
.await
.unwrap();
resolve_pending_download_urls(&db, "/data", None, |item_id, media_type, _q| async move {
Some(ResolvedDownloadUrl {
url: format!("http://resolved/{item_id}"),
expected_bytes: (media_type == "video").then_some(1_500_000_000),
})
})
.await
.unwrap();
let size = |item: &'static str| {
let db = Arc::clone(&db);
async move {
db.query_one(
Query::with_params(
"SELECT file_size FROM downloads WHERE item_id = ?",
vec![QueryParam::String(item.to_string())],
),
|row| row.get::<_, Option<i64>>(0),
)
.await
.unwrap()
}
};
assert_eq!(size("film").await, Some(1_500_000_000));
assert_eq!(size("track").await, Some(777));
}
#[tokio::test]
async fn video_rows_use_media_type_in_resolver() {
let db = test_db();
@@ -1134,7 +1217,7 @@ mod tests {
None,
|item_id, media_type, _q| async move {
assert_eq!(media_type, "video");
Some(format!("http://transcode/{item_id}"))
Some(format!("http://transcode/{item_id}").into())
},
)
.await
+36 -15
View File
@@ -759,7 +759,7 @@ pub async fn download_album(
async move {
use crate::repository::MediaRepository;
match repo.get_audio_stream_url(&item_id).await {
Ok(url) => Some(url),
Ok(url) => Some(url.into()),
Err(e) => {
warn!(
"[download_album] Failed to resolve stream URL for {}: {:?}",
@@ -1604,6 +1604,9 @@ pub async fn start_download(
item_id,
stream_url,
target_path,
file_size_from_server
.or(file_size)
.and_then(|n| u64::try_from(n).ok()),
active_downloads,
);
@@ -1703,15 +1706,20 @@ pub async fn enqueue_video_downloads(
// Build the download URL, resolving the source's audio codec first so a
// track this device cannot decode is re-encoded on the way down rather
// than saved as a silent file (DR-167).
let stream_url =
crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
let resolved =
crate::repository::resolve_video_download(repo.as_ref(), &item_id, &quality, None)
.await;
// The predicted size becomes the row's `file_size` so the worker has a
// total to report against when the response has none (DR-290). A
// size the server states later replaces it on completion.
let update_query = Query::with_params(
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ?, \
file_size = COALESCE(?, file_size) WHERE id = ?",
vec![
QueryParam::String(stream_url),
QueryParam::String(resolved.url),
QueryParam::String(target_dir.clone()),
expected_bytes_param(resolved.expected_bytes),
QueryParam::Int64(download_id),
],
);
@@ -1819,7 +1827,7 @@ pub(crate) async fn pump_download_queue(
// Find the next pending, startable download (has a stream URL). Exclude
// anything already registered as active to avoid double-starting.
let next_query = Query::with_params(
"SELECT id, item_id, file_path, stream_url, target_dir
"SELECT id, item_id, file_path, stream_url, target_dir, file_size
FROM downloads
WHERE status = 'pending'
AND stream_url IS NOT NULL
@@ -1828,7 +1836,7 @@ pub(crate) async fn pump_download_queue(
vec![],
);
let candidates: Vec<(i64, String, String, String, String)> = match db_service
let candidates: Vec<(i64, String, String, String, String, Option<i64>)> = match db_service
.query_many(next_query, |row| {
Ok((
row.get(0)?,
@@ -1836,6 +1844,7 @@ pub(crate) async fn pump_download_queue(
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
))
})
.await
@@ -1848,14 +1857,14 @@ pub(crate) async fn pump_download_queue(
};
// Pick the first candidate not already active.
let next = candidates.into_iter().find(|(id, _, _, _, _)| {
let next = candidates.into_iter().find(|(id, _, _, _, _, _)| {
active_downloads
.lock()
.map(|active| !active.contains(id))
.unwrap_or(false)
});
let (download_id, item_id, file_path, stream_url, target_dir) = match next {
let (download_id, item_id, file_path, stream_url, target_dir, file_size) = match next {
Some(n) => n,
None => return, // Nothing pending to start
};
@@ -1943,11 +1952,20 @@ pub(crate) async fn pump_download_queue(
item_id,
stream_url,
target_path,
file_size.and_then(|n| u64::try_from(n).ok()),
active_downloads.clone(),
);
}
}
/// A predicted size as a bind parameter: `NULL` keeps whatever the row holds.
fn expected_bytes_param(expected: Option<u64>) -> QueryParam {
match expected.and_then(|n| i64::try_from(n).ok()) {
Some(n) => QueryParam::Int64(n),
None => QueryParam::Null,
}
}
/// Spawn the background worker for one download. On completion or failure it
/// unregisters the slot, emits the terminal event, and pumps the queue so the
/// next pending download starts automatically.
@@ -1957,6 +1975,7 @@ fn spawn_download_worker(
item_id: String,
stream_url: String,
target_path: std::path::PathBuf,
expected_bytes: Option<u64>,
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
) {
use crate::download::events::DownloadEvent;
@@ -1975,18 +1994,19 @@ fn spawn_download_worker(
// Progress callback that emits events to the frontend
let progress_app = app.clone();
let progress_item_id = item_id.clone();
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
let progress = total_bytes
.filter(|&t| t > 0)
.map(|t| bytes_downloaded as f64 / t as f64)
.unwrap_or(0.0);
let on_progress = move |bytes_downloaded: u64, content_length: Option<u64>| {
// The server's length when it gave one; the prediction made at
// resolve time when it did not (a transcode). DR-290
let total = crate::download::estimate::progress_total(content_length, expected_bytes);
let progress = crate::download::estimate::progress_fraction(bytes_downloaded, total);
let event = DownloadEvent::Progress {
download_id,
item_id: progress_item_id.clone(),
bytes_downloaded: bytes_downloaded as i64,
total_bytes: total_bytes.map(|t| t as i64),
total_bytes: total.map(|t| t.bytes as i64),
progress,
estimated: total.is_some_and(|t| t.estimated),
};
let _ = progress_app.emit("download-event", event);
};
@@ -2060,6 +2080,7 @@ fn spawn_download_worker(
download_id,
item_id,
file_path,
bytes_downloaded: res.bytes_downloaded as i64,
};
match app.emit("download-event", completed_event) {
Ok(_) => debug!(" Completed event emitted successfully"),
+55
View File
@@ -2187,6 +2187,25 @@ pub struct PlaybackCapabilities {
/// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
/// compositing), so it stays on the HTML5 element.
pub supports_native_video: bool,
/// True when the user may send video to the webview element instead of the
/// native renderer — the frontend offers the switch only then, and honours
/// the stored preference only then. See [`webview_video_fallback`].
pub webview_video_fallback: bool,
}
/// Whether the user may send video to the webview `<video>` element instead of
/// the native renderer.
///
/// Never on Android: ExoPlayer is its only video renderer. Downloads there are
/// the untouched source file (DR-293), and the webview decodes none of the
/// AC-3/E-AC-3/DTS/TrueHD that ExoPlayer plays through the FFmpeg extension, so
/// the fallback would be a silent film. Beside mpv's native video on Linux the
/// webview is still the tested fallback; everywhere else it is the only
/// renderer and there is nothing to switch.
///
/// TRACES: UR-003, UR-071 | DR-293 | UT-259
pub fn webview_video_fallback(is_android: bool, native_video_enabled: bool) -> bool {
!is_android && native_video_enabled
}
/// Report this platform's playback capabilities to the frontend.
@@ -2203,6 +2222,11 @@ pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
// TRACES: UR-080 | DR-235
supports_native_video: cfg!(target_os = "android")
|| crate::player::native_video::enabled(),
// TRACES: UR-003, UR-071 | DR-293
webview_video_fallback: webview_video_fallback(
cfg!(target_os = "android"),
crate::player::native_video::enabled(),
),
})
}
@@ -3058,6 +3082,37 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
mod tests {
use crate::utils::lock::MutexSafe;
/// Android has one video renderer, ExoPlayer. The webview element could only
/// be reached by the user switching native video off, and a file downloaded
/// as the untouched original — AC-3 audio included — plays silent there,
/// so the switch is gone on Android (DR-293). Where mpv draws video on Linux
/// the webview is still the tested fallback, so the switch stays there;
/// everywhere else the webview is the only renderer and there is nothing to
/// switch.
///
/// TRACES: UR-003, UR-071 | DR-293 | UT-259
#[test]
fn test_webview_video_fallback_is_offered_only_beside_mpv_native_video() {
use super::webview_video_fallback;
assert!(
!webview_video_fallback(true, false),
"Android: ExoPlayer is the only video renderer"
);
assert!(
!webview_video_fallback(true, true),
"Android never falls back, whatever else is switched on"
);
assert!(
webview_video_fallback(false, true),
"Linux with mpv native video: the webview is the fallback"
);
assert!(
!webview_video_fallback(false, false),
"the webview is the only renderer; nothing to fall back from"
);
}
/// UT-206 — the volume the command hands on is always a real number in
/// 0.0..=1.0.
///
+181
View File
@@ -0,0 +1,181 @@
//! How big a download is going to be when the server will not say.
//!
//! A direct copy answers with `Content-Length`, and the worker reports exact
//! progress from it. A transcode is produced as it is sent — chunked, with no
//! length — and the worker used to report `progress: 0.0` for its whole
//! duration: an empty bar and "0%" while the byte count climbed for an hour.
//! That is the case every film whose audio must be re-encoded lands in.
//!
//! The backend does know enough to estimate. It fetches the item to decide the
//! audio policy anyway, and that item carries the source's size and runtime;
//! the preset it chose fixes the bitrate. So the estimate is made where the
//! URL is, persisted on the row as its `file_size`, and used only as a
//! fallback: a real `Content-Length` always wins, and an estimated bar never
//! claims completion.
//!
//! TRACES: UR-071 | DR-290
use super::presets::download_preset;
/// Ticks per second in Jellyfin's runtime unit.
const TICKS_PER_SECOND: u64 = 10_000_000;
/// The progress bar never reports more than this from an estimate, so a source
/// that encodes a little larger than predicted shows 99% until the last byte
/// rather than 104% — completion is the worker's to announce.
pub const ESTIMATED_PROGRESS_CEILING: f64 = 0.99;
/// The size a download for `quality` is expected to produce, in bytes.
///
/// - A preset re-encodes both streams at fixed rates, so the size is rate ×
/// runtime. Jellyfin encodes to a target bitrate (`-b:v` with `-maxrate`), so
/// the average lands near the cap rather than well under it.
/// - `original` copies the picture and at most re-encodes the audio, so the
/// output is the source's size give or take the audio track — and when no
/// transcode is needed at all it is exactly the source's size.
///
/// `None` when the inputs needed are missing; the caller then has no total and
/// the bar is indeterminate, which is honest and was the status quo.
///
/// TRACES: UR-071 | DR-290 | UT-252
pub fn expected_download_bytes(
quality: &str,
runtime_ticks: Option<i64>,
source_size: Option<i64>,
) -> Option<u64> {
match download_preset(quality) {
Some(preset) => {
let seconds = u64::try_from(runtime_ticks?).ok()? / TICKS_PER_SECOND;
(seconds > 0).then(|| preset.total_bit_rate() / 8 * seconds)
}
None => source_size
.and_then(|s| u64::try_from(s).ok())
.filter(|&s| s > 0),
}
}
/// What the progress bar measures against.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProgressTotal {
pub bytes: u64,
/// The total is a prediction, not the server's word.
pub estimated: bool,
}
/// The total to report progress against, given what the response said and what
/// was predicted before it was made. The server's `Content-Length` always
/// wins; the estimate fills in only when the server sent none.
///
/// TRACES: UR-071 | DR-290 | UT-252
pub fn progress_total(content_length: Option<u64>, expected: Option<u64>) -> Option<ProgressTotal> {
match (
content_length.filter(|&n| n > 0),
expected.filter(|&n| n > 0),
) {
(Some(bytes), _) => Some(ProgressTotal {
bytes,
estimated: false,
}),
(None, Some(bytes)) => Some(ProgressTotal {
bytes,
estimated: true,
}),
(None, None) => None,
}
}
/// The fraction complete, in `0.0..=1.0`. An estimated total is capped at
/// [`ESTIMATED_PROGRESS_CEILING`] so a prediction that ran low never shows a
/// finished bar on a download still running.
///
/// TRACES: UR-071 | DR-290 | UT-252
pub fn progress_fraction(downloaded: u64, total: Option<ProgressTotal>) -> f64 {
let Some(total) = total else { return 0.0 };
let fraction = downloaded as f64 / total.bytes as f64;
let ceiling = if total.estimated {
ESTIMATED_PROGRESS_CEILING
} else {
1.0
};
fraction.clamp(0.0, ceiling)
}
#[cfg(test)]
mod tests {
use super::*;
const HOUR_TICKS: i64 = 3600 * TICKS_PER_SECOND as i64;
/// A transcode has no `Content-Length`, and this is the case that showed
/// "0%" for its whole duration: with a prediction in hand the bar must move.
///
/// TRACES: UR-071 | DR-290 | UT-252
#[test]
fn test_estimate_fills_in_when_the_server_sent_no_length() {
let total = progress_total(None, Some(4_000));
assert_eq!(
total,
Some(ProgressTotal {
bytes: 4_000,
estimated: true
})
);
let fraction = progress_fraction(1_000, total);
assert!((fraction - 0.25).abs() < 1e-9, "got {fraction}");
}
/// The server's own figure is never second-guessed by a prediction.
#[test]
fn test_content_length_wins_over_the_estimate() {
let total = progress_total(Some(10_000), Some(4_000)).unwrap();
assert_eq!(total.bytes, 10_000);
assert!(!total.estimated);
assert_eq!(progress_fraction(10_000, Some(total)), 1.0);
}
/// A prediction that ran low must not announce completion: that is the
/// worker's to do when the last byte lands.
#[test]
fn test_estimated_progress_never_reaches_one() {
let total = progress_total(None, Some(1_000));
assert_eq!(progress_fraction(1_200, total), ESTIMATED_PROGRESS_CEILING);
assert_eq!(progress_fraction(0, total), 0.0);
}
/// Nothing known → nothing claimed, and a zero length is "nothing known".
#[test]
fn test_no_total_means_no_progress_claim() {
assert_eq!(progress_total(None, None), None);
assert_eq!(progress_total(Some(0), Some(0)), None);
assert_eq!(progress_fraction(500, None), 0.0);
}
/// A preset's size is its combined rate over the runtime — one hour of the
/// medium preset (4 Mb/s + 256 kb/s) is about 1.9 GB.
#[test]
fn test_preset_estimate_is_rate_times_runtime() {
let bytes = expected_download_bytes("medium", Some(HOUR_TICKS), Some(9_999)).unwrap();
assert_eq!(bytes, (4_000_000 + 256_000) / 8 * 3600);
// Without a runtime there is nothing to multiply.
assert_eq!(expected_download_bytes("medium", None, Some(9_999)), None);
assert_eq!(expected_download_bytes("medium", Some(0), None), None);
}
/// `original` copies the picture, so the source's size is the prediction —
/// with or without the audio being re-encoded on the way.
#[test]
fn test_original_estimate_is_the_source_size() {
assert_eq!(
expected_download_bytes("original", Some(HOUR_TICKS), Some(3_000_000_000)),
Some(3_000_000_000)
);
assert_eq!(
expected_download_bytes("original", Some(HOUR_TICKS), None),
None
);
assert_eq!(expected_download_bytes("original", None, Some(0)), None);
// An unknown quality name is treated as original by the URL builder,
// so it is here too.
assert_eq!(expected_download_bytes("wat", None, Some(10)), Some(10));
}
}
+10
View File
@@ -20,6 +20,10 @@ pub enum DownloadEvent {
bytes_downloaded: i64,
total_bytes: Option<i64>,
progress: f64, // 0.0 to 1.0
/// `total_bytes` is a prediction rather than the server's
/// `Content-Length`, so `progress` stops short of 1.0 until the
/// download completes. TRACES: UR-071 | DR-290
estimated: bool,
},
/// Download completed successfully
#[serde(rename_all = "camelCase")]
@@ -27,6 +31,10 @@ pub enum DownloadEvent {
download_id: i64,
item_id: String,
file_path: String,
/// Bytes actually written. The frontend persists completion too, and
/// without this it fell back to the row's `file_size` — which is a
/// prediction for a transcode (DR-290), not the real size.
bytes_downloaded: i64,
},
/// Download failed with error
#[serde(rename_all = "camelCase")]
@@ -60,6 +68,7 @@ mod tests {
bytes_downloaded: 1024,
total_bytes: Some(2048),
progress: 0.5,
estimated: false,
};
let json = serde_json::to_string(&event).unwrap();
@@ -86,6 +95,7 @@ mod tests {
download_id: 42,
item_id: "song456".to_string(),
file_path: "/path/to/file.mp3".to_string(),
bytes_downloaded: 4096,
};
let json = serde_json::to_string(&event).unwrap();
+2
View File
@@ -7,8 +7,10 @@
//! - Resume support via HTTP Range requests
pub mod cache;
pub mod estimate;
pub mod events;
pub mod network;
pub mod presets;
pub mod stop;
pub mod worker;
+66
View File
@@ -0,0 +1,66 @@
//! The quality presets a video download can be asked for.
//!
//! One table, read by both the URL builder (which turns a preset into transcode
//! parameters) and the size estimate (which turns the same numbers into an
//! expected byte count). They were the same literals in two places before,
//! which is how a bar can claim 40% of a file that is nearly done.
/// Transcode caps for one named preset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DownloadPreset {
/// Video target, bits per second.
pub video_bit_rate: u64,
/// Longest edge the picture is scaled down to.
pub max_height: u32,
/// Audio target, bits per second.
pub audio_bit_rate: u64,
}
impl DownloadPreset {
/// Combined stream rate, bits per second.
pub fn total_bit_rate(&self) -> u64 {
self.video_bit_rate + self.audio_bit_rate
}
}
/// The preset a quality name denotes; `None` for `original` and anything
/// unrecognised, both of which mean "do not cap the picture".
///
/// TRACES: UR-071 | DR-123, DR-290
pub fn download_preset(quality: &str) -> Option<DownloadPreset> {
match quality {
"high" => Some(DownloadPreset {
video_bit_rate: 8_000_000,
max_height: 1080,
audio_bit_rate: 384_000,
}),
"medium" => Some(DownloadPreset {
video_bit_rate: 4_000_000,
max_height: 720,
audio_bit_rate: 256_000,
}),
"low" => Some(DownloadPreset {
video_bit_rate: 1_500_000,
max_height: 480,
audio_bit_rate: 128_000,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_presets_are_ordered_and_original_has_none() {
let high = download_preset("high").unwrap();
let medium = download_preset("medium").unwrap();
let low = download_preset("low").unwrap();
assert!(high.total_bit_rate() > medium.total_bit_rate());
assert!(medium.total_bit_rate() > low.total_bit_rate());
assert!(high.max_height > medium.max_height && medium.max_height > low.max_height);
assert_eq!(download_preset("original"), None);
assert_eq!(download_preset("nonsense"), None);
}
}
+145 -2
View File
@@ -18,11 +18,40 @@ pub struct DownloadWorker {
max_retries: u32,
}
/// How long a transfer may go without receiving a single byte before it is
/// treated as dead and retried. Generous because a transcode download waits on
/// ffmpeg, which pauses when Jellyfin throttles it.
const STALL_TIMEOUT: Duration = Duration::from_secs(60);
/// How long to wait for the TCP/TLS handshake before giving up.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
impl DownloadWorker {
pub fn new() -> Self {
Self::with_stall_timeout(STALL_TIMEOUT, true)
}
/// Build a worker whose HTTP client gives up on a transfer that receives
/// nothing for `stall`. `https_only` is relaxed only by tests, which serve
/// from a loopback socket.
///
/// The timeouts are a *connect* timeout and a *read* timeout — never
/// `Client::timeout`. That one is a total deadline that runs until the body
/// has finished, and it was set to five minutes: every download longer than
/// that was cut off mid-body with "error decoding response body", then
/// retried. A transcode ignores `Range`, so each retry restarted from byte
/// zero, ran into the same five minutes, and after three attempts the
/// download failed — which is why no feature film at transcode speed ever
/// completed on a device that needs the audio re-encoded. A read timeout
/// resets on every chunk, so it catches a dead connection without putting a
/// ceiling on how long a healthy transfer may run.
///
/// TRACES: UR-071 | DR-289 | UT-251
fn with_stall_timeout(stall: Duration, https_only: bool) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300)) // 5 minute timeout
.https_only(true)
.connect_timeout(CONNECT_TIMEOUT)
.read_timeout(stall)
.https_only(https_only)
.build()
.expect("Failed to create HTTP client");
@@ -436,3 +465,117 @@ mod tests {
assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
}
}
/// Transfers that outlive the timeout. These drive the real `reqwest` client
/// against a loopback socket because the defect lived in how that client was
/// configured, not in any code of ours a mock could stand in for.
#[cfg(test)]
mod timeout_tests {
use super::*;
use tokio::net::TcpListener;
/// Serve one HTTP/1.1 response of `chunks` bodies of `chunk_len` bytes,
/// pausing `gap` between them. `hang_after` chunks, the server stops sending
/// and never closes — a stalled connection.
async fn dribbling_server(
chunks: usize,
chunk_len: usize,
gap: Duration,
hang_after: Option<usize>,
) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
// Drain the request head; we answer the same thing regardless.
let mut buf = [0u8; 4096];
let _ = tokio::io::AsyncReadExt::read(&mut sock, &mut buf).await;
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
chunks * chunk_len
);
if sock.write_all(head.as_bytes()).await.is_err() {
return;
}
let body = vec![b'x'; chunk_len];
for i in 0..chunks {
if hang_after == Some(i) {
// Hold the socket open forever without writing.
tokio::time::sleep(Duration::from_secs(3600)).await;
}
// The client hanging up (as it does once it times out) is not
// the server's failure to report.
if sock.write_all(&body).await.is_err() || sock.flush().await.is_err() {
return;
}
tokio::time::sleep(gap).await;
}
});
format!("http://{}/file.bin", addr)
}
/// A download that takes longer than the timeout but never stalls must
/// finish. The worker set `Client::timeout`, which in reqwest is a *total*
/// deadline covering the body, so every transfer longer than five minutes —
/// any film at transcode speed — was cut off with "error decoding response
/// body", retried from byte zero (a transcode ignores `Range`), and cut off
/// again until the retry budget ran out.
///
/// TRACES: UR-071 | DR-289 | UT-251
#[tokio::test]
async fn test_download_longer_than_the_stall_timeout_completes_when_bytes_keep_flowing() {
let stall = Duration::from_millis(400);
// 12 chunks × 100 ms ≈ 1.2 s of transfer, three times the stall timeout,
// with every gap comfortably inside it.
let url = dribbling_server(12, 1024, Duration::from_millis(100), None).await;
let dir = tempfile::tempdir().unwrap();
let task = DownloadTask {
url,
target_path: dir.path().join("file.bin"),
};
let worker = DownloadWorker::with_stall_timeout(stall, false);
let result = worker
.download(&task, &AtomicBool::new(false), |_, _| {})
.await;
let result = result
.unwrap_or_else(|e| panic!("a transfer that never stalls must not time out: {e:?}"));
assert_eq!(result.bytes_downloaded, 12 * 1024);
assert!(task.target_path.exists());
}
/// The converse: a connection that goes silent is still given up on, so
/// dropping the total deadline did not turn a dead wifi link into a download
/// that hangs forever with no retry.
///
/// TRACES: UR-071 | DR-289 | UT-251
#[tokio::test]
async fn test_download_that_stalls_is_given_up_on() {
let stall = Duration::from_millis(300);
let url = dribbling_server(4, 1024, Duration::from_millis(10), Some(2)).await;
let dir = tempfile::tempdir().unwrap();
let task = DownloadTask {
url,
target_path: dir.path().join("file.bin"),
};
let worker = DownloadWorker::with_stall_timeout(stall, false);
// `download()` retries with 5 s/15 s/45 s backoff; a single attempt is
// what proves the stall is detected.
let started = std::time::Instant::now();
let result = worker
.try_download(&task, &AtomicBool::new(false), &|_, _| {})
.await;
assert!(
matches!(result, Err(DownloadError::Network(_))),
"a stalled transfer must fail as a network error: {result:?}"
);
assert!(
started.elapsed() < Duration::from_secs(5),
"the stall must be detected promptly, took {:?}",
started.elapsed()
);
}
}
+12 -11
View File
@@ -195,17 +195,15 @@ pub fn subtitle_supports_external_delivery(codec: Option<&str>) -> bool {
/// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
/// the user gets picture with no sound.
///
/// Which renderer gets it is not fixed: Linux is always the element, and Android
/// follows `experimentalNativeVideo`, which took ExoPlayer as its default in
/// DR-161 but is a user setting either way. So the *narrow* list is the only one
/// that holds on both sides of that switch. The cost is a Dolby-licensed Android
/// device transcoding an E-AC-3 track its ExoPlayer could have direct-played;
/// the alternative is silence for everyone the switch lands the other way, which
/// is the bug this exists to prevent.
///
/// The gap is widest on devices whose vendor licenses Dolby: a phone with
/// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
/// direct play where a leaner device is transcoded to AAC and plays fine.
/// Which renderer gets it depends on the platform. Linux draws video in the
/// element (unless mpv native video is switched on), so it gets the narrow list.
/// Android draws video only in ExoPlayer: it used to follow the
/// `experimentalNativeVideo` setting, which could send video to the webview, and
/// while that switch existed the narrow list was the only one true on both sides
/// of it. DR-293 removed the webview video path on Android, so there the
/// platform list is the whole answer — it includes the FFmpeg extension's
/// AC-3/E-AC-3/DTS/TrueHD, which `CodecDetector` reports alongside the
/// `MediaCodecList` decoders.
///
/// This applies to the *video* direct-play profile only. Audio-only playback
/// really is ExoPlayer's, so its profile keeps the full platform list.
@@ -323,6 +321,9 @@ pub fn renderer_can_decode_audio(codec: &str) -> bool {
/// Whether the webview `<video>` element can decode this audio codec.
///
/// TRACES: UR-004 | DR-149 | UT-148
// Unreachable on Android since DR-293: video renders only in ExoPlayer there,
// so every caller goes through `renderer_can_decode_audio`'s device-list arm.
#[cfg_attr(target_os = "android", allow(dead_code))]
pub fn webview_can_decode_audio(codec: &str) -> bool {
WEBVIEW_AUDIO_CODECS
.iter()
+125 -41
View File
@@ -235,7 +235,10 @@ impl HybridRepository {
) -> Result<SearchResult, RepoError> {
let offline = Arc::clone(&self.offline);
let query = query.to_string();
self.cache_with_timeout(async move { offline.search(&query, options).await })
// Cache-only: there is no server to fall back to, so a busy database
// delays the answer rather than failing it (DR-294).
offline
.search(&query, options)
.await
.map(ExcludeHidden::without_excluded)
}
@@ -250,7 +253,9 @@ impl HybridRepository {
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
let offline = Arc::clone(&self.offline);
self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
// Cache-only, as `search_cache_only` above (DR-294).
offline
.get_favorites(scope, options)
.await
.map(ExcludeHidden::without_excluded)
}
@@ -394,7 +399,7 @@ impl HybridRepository {
/// Cache-first query: try cache, fall back to server on miss.
///
/// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
/// 1. Check cache (100ms fast path, via `cache_leg`; a slow read keeps running)
/// 2. If cache has meaningful content → return immediately (fast path)
/// 3. If cache is empty/stale → query server (fresh data)
/// 4. If server fails → return cache even if empty (offline fallback)
@@ -523,18 +528,64 @@ impl HybridRepository {
}
}
/// Await a cache query that is still running, however long it takes.
async fn cache_with_timeout<T>(
&self,
future: impl std::future::Future<Output = Result<T, RepoError>> + Send,
) -> Result<T, RepoError> {
timeout(Self::CACHE_FAST_PATH, future)
.await
.unwrap_or_else(|_| {
Err(RepoError::Database {
message: "Cache query timeout".to_string(),
})
/// Start a cache read with [`Self::CACHE_FAST_PATH`] to answer: its result
/// if it made it, and otherwise the read itself, still running.
///
/// The cache-then-server queries used to *discard* a read that missed the
/// deadline. The database is one SQLite connection behind one mutex, so any
/// write in progress — the catalog sync that starts at every launch, a
/// download finishing — pushes a read past 100 ms routinely; offline the
/// server then failed too, and the page reported a network error over data
/// sitting on disk. Keeping the read lets [`Self::settle`] wait for it.
///
/// TRACES: UR-002 | DR-013, DR-294
async fn cache_try<T>(
future: impl std::future::Future<Output = Result<T, RepoError>> + Send + 'static,
) -> (
Result<T, RepoError>,
Option<tokio::task::JoinHandle<Result<T, RepoError>>>,
)
where
T: Send + 'static,
{
let (fast, slow) = Self::cache_leg(future).await.split();
let fast = fast.unwrap_or_else(|| {
Err(RepoError::Database {
message: "Cache query still running".to_string(),
})
});
(fast, slow)
}
/// The server could not answer: the cache's answer if it has one —
/// waiting for a read still in flight — else the server's error.
///
/// TRACES: UR-002 | DR-294 | UT-263
async fn settle<T>(
fast: Result<T, RepoError>,
slow: Option<tokio::task::JoinHandle<Result<T, RepoError>>>,
server_err: RepoError,
) -> Result<T, RepoError> {
if let Some(handle) = slow {
debug!("[HybridRepo] Server failed; waiting for the slow cache read");
return match handle.await {
Ok(Ok(data)) => Ok(data),
_ => Err(server_err),
};
}
fast.or(Err(server_err))
}
/// [`Self::settle`] for `get_items`, whose slow read has not yet had
/// exclusions applied.
async fn cache_or(
fast: Result<SearchResult, RepoError>,
slow: Option<tokio::task::JoinHandle<Result<SearchResult, RepoError>>>,
server_err: RepoError,
) -> Result<SearchResult, RepoError> {
Self::settle(fast, slow, server_err)
.await
.map(ExcludeHidden::without_excluded)
}
}
@@ -544,7 +595,9 @@ impl MediaRepository for HybridRepository {
// Cache-first (100ms). On a cache hit, refresh the cache from the server
// in the background. On a miss, fetch from the server and persist so the
// list is available on the next (possibly offline) startup.
let cache_result = self.cache_with_timeout(self.offline.get_libraries()).await;
let offline_read = Arc::clone(&self.offline);
let (cache_result, slow_cache) =
Self::cache_try(async move { offline_read.get_libraries().await }).await;
if let Ok(libs) = &cache_result {
if libs.has_content() {
@@ -581,7 +634,8 @@ impl MediaRepository for HybridRepository {
}
Ok(server_libs)
}
Err(e) => cache_result.or(Err(e)),
// TRACES: UR-002 | DR-294 | UT-263
Err(e) => Self::settle(cache_result, slow_cache, e).await,
}
}
@@ -610,10 +664,16 @@ impl MediaRepository for HybridRepository {
// `parallel_race` — it interleaves the downloads-only gate and a
// background cache write — so it applies the filter itself.
// TRACES: UR-076 | DR-209
let cache_result = self
.cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
.await
.map(ExcludeHidden::without_excluded);
//
// A read that misses the fast path is kept running, not discarded: if
// the server then fails, the cache is the only thing that can answer,
// and it is waited for (see the end of this function). Discarding it
// is what made a downloaded show fail offline ("Failed to load item")
// whenever a write held the database past 100 ms — the catalog sync
// that starts at every launch does, routinely. TRACES: UR-002 | DR-294
let (cache_result, slow_cache) =
Self::cache_try(async move { offline.get_items(&parent_id, opts_clone).await }).await;
let cache_result = cache_result.map(ExcludeHidden::without_excluded);
// Downloads-only gate: when the "Show all server media" toggle is off
// (offline), an empty offline result is authoritative — the user asked
@@ -697,10 +757,13 @@ impl MediaRepository for HybridRepository {
// TRACES: UR-076 | DR-209
Ok(server_data.without_excluded())
}
Ok(Err(e)) => cache_result.or(Err(e)),
Err(join_err) => cache_result.or(Err(RepoError::Network {
message: format!("Server task failed: {}", join_err),
})),
Ok(Err(e)) => Self::cache_or(cache_result, slow_cache, e).await,
Err(join_err) => {
let e = RepoError::Network {
message: format!("Server task failed: {}", join_err),
};
Self::cache_or(cache_result, slow_cache, e).await
}
}
}
@@ -806,11 +869,22 @@ impl MediaRepository for HybridRepository {
series_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
// Next up is dynamic, always fetch from server
self.online
.get_next_up_episodes(series_id, limit)
.await
.map(ExcludeHidden::without_excluded)
// Next Up is dynamic, so the server's answer is preferred — but when the
// server cannot answer, the cache's stands in. It used to be server-only,
// and the TV landing page loads Next Up 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 (DR-294).
// TRACES: UR-002 | DR-294 | UT-261
match self.online.get_next_up_episodes(series_id, limit).await {
Ok(items) => Ok(items.without_excluded()),
Err(e) => {
debug!("[HybridRepo] Next Up from server failed ({e}); using the cache");
self.offline
.get_next_up_episodes(series_id, limit)
.await
.map(ExcludeHidden::without_excluded)
}
}
}
async fn get_recently_played_audio(
@@ -878,9 +952,9 @@ impl MediaRepository for HybridRepository {
let cache_offline = Arc::clone(&self.offline);
let cache_pid = parent_id_str.clone();
let cache_result = self
.cache_with_timeout(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
.await;
let (cache_result, slow_cache) =
Self::cache_try(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
.await;
if let Ok(genres) = &cache_result {
if genres.has_content() {
@@ -922,7 +996,7 @@ impl MediaRepository for HybridRepository {
}
Ok(server_genres)
}
Err(e) => cache_result.or(Err(e)),
Err(e) => Self::settle(cache_result, slow_cache, e).await,
}
}
@@ -946,7 +1020,15 @@ impl MediaRepository for HybridRepository {
}
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
// Playback info requires server communication for transcoding decisions
// A downloaded item is played from disk and needs nothing the server
// negotiates, so its answer comes from the download row — first, and
// whether or not the server is reachable. Asking the server first is
// what made a download unplayable offline (DR-294). Anything not held
// locally is a streaming question, and only the server can answer it.
// TRACES: UR-002, UR-071 | DR-294 | UT-260
if let Some(local) = self.offline.local_playback_info(item_id).await? {
return Ok(local);
}
self.online.get_playback_info(item_id).await
}
@@ -1205,9 +1287,8 @@ impl MediaRepository for HybridRepository {
tokio::spawn(async move { online.get_playlist_items(&playlist_id_clone).await });
// Check cache first (fast, 100ms timeout)
let cache_result = self
.cache_with_timeout(async move { offline.get_playlist_items(&playlist_id).await })
.await;
let (cache_result, slow_cache) =
Self::cache_try(async move { offline.get_playlist_items(&playlist_id).await }).await;
// Cache hit: return immediately, update cache in background
if let Ok(data) = &cache_result {
@@ -1244,10 +1325,13 @@ impl MediaRepository for HybridRepository {
});
Ok(entries)
}
Ok(Err(e)) => cache_result.or(Err(e)),
Err(join_err) => cache_result.or(Err(RepoError::Network {
message: format!("Server task failed: {}", join_err),
})),
Ok(Err(e)) => Self::settle(cache_result, slow_cache, e).await,
Err(join_err) => {
let e = RepoError::Network {
message: format!("Server task failed: {}", join_err),
};
Self::settle(cache_result, slow_cache, e).await
}
}
}
+57 -21
View File
@@ -213,7 +213,7 @@ pub trait MediaRepository: Send + Sync {
/// [`resolve_video_download_url`] rather than calling it directly.
///
/// `source_audio_codec` is the codec of the audio track the server would
/// serve (see [`served_audio_codec`]); `None` when it is not known. At
/// serve (see [`resolve_video_download`]); `None` when it is not known. At
/// `original` quality it decides whether the file can be copied byte-for-byte
/// or has to have its audio re-encoded on the way down — a downloaded file is
/// played back with no server in reach, so it has to be decodable *here*.
@@ -345,35 +345,70 @@ pub trait MediaRepository: Send + Sync {
) -> Result<(), RepoError>;
}
/// The audio codec the server would serve for `item_id` — the default track, or
/// the first when none is marked, matching the track Jellyfin picks.
/// A video download, resolved: the URL to fetch and, where the item told us
/// enough, how many bytes to expect from it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedVideoDownload {
pub url: String,
/// Predicted size (see `download::estimate`), used as the progress total
/// when the response carries no `Content-Length` — a transcode never does.
pub expected_bytes: Option<u64>,
}
/// Resolve the download URL for a video, applying the audio-codec policy that
/// keeps the saved file playable offline (DR-171), and predict its size from
/// the same item lookup (DR-290).
///
/// `None` when the item has no audio, names no codec, or cannot be fetched. A
/// caller must read that as "unknown", never as "fine": it is the input to a
/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
/// exactly as it was.
/// Every video download goes through here rather than calling the builder
/// directly: the builder is pure and cannot look the codec up, and a caller that
/// forgets to is exactly how the silent downloads shipped.
///
/// TRACES: UR-071 | DR-171 | UT-166
pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
let item = repo.get_item(item_id).await.ok()?;
/// TRACES: UR-071 | DR-171, DR-290
pub async fn resolve_video_download(
repo: &dyn MediaRepository,
item_id: &str,
quality: &str,
media_source_id: Option<&str>,
) -> ResolvedVideoDownload {
let item = repo.get_item(item_id).await.ok();
let audio: Vec<(Option<&str>, bool)> = item
.media_streams
.as_deref()
.as_ref()
.and_then(|i| i.media_streams.as_deref())
.unwrap_or_default()
.iter()
.filter(|s| s.stream_type == "Audio")
.map(|s| (s.codec.as_deref(), s.is_default))
.collect();
// The default track, or the first when none is marked, matching the track
// Jellyfin picks. `None` reads as "unknown", never as "fine": it feeds a
// policy that only *adds* a transcode, so an unknown codec leaves behaviour
// exactly as it was. TRACES: UR-071 | DR-171 | UT-166
let codec = device_profile::served_audio_codec(&audio);
device_profile::served_audio_codec(&audio).map(str::to_string)
// The source that will be served: the one asked for, else the first —
// the same choice Jellyfin makes when no `mediaSourceId` is given.
let source_size = item.as_ref().and_then(|i| {
let sources = i.media_sources.as_deref()?;
let source = match media_source_id {
Some(id) => sources.iter().find(|s| s.id == id),
None => sources.first(),
};
source?.size
});
let expected_bytes = crate::download::estimate::expected_download_bytes(
quality,
item.as_ref().and_then(|i| i.runtime_ticks),
source_size,
);
ResolvedVideoDownload {
url: repo.get_video_download_url(item_id, quality, media_source_id, codec),
expected_bytes,
}
}
/// Resolve the download URL for a video, applying the audio-codec policy that
/// keeps the saved file playable offline (DR-171).
///
/// Every video download goes through here rather than calling the builder
/// directly: the builder is pure and cannot look the codec up, and a caller that
/// forgets to is exactly how the silent downloads shipped.
/// [`resolve_video_download`] for callers that only need the URL.
///
/// TRACES: UR-071 | DR-171
pub async fn resolve_video_download_url(
@@ -382,6 +417,7 @@ pub async fn resolve_video_download_url(
quality: &str,
media_source_id: Option<&str>,
) -> String {
let codec = served_audio_codec(repo, item_id).await;
repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
resolve_video_download(repo, item_id, quality, media_source_id)
.await
.url
}
+314 -1
View File
@@ -115,6 +115,54 @@ pub struct OfflineRepository {
}
impl OfflineRepository {
/// Playback info for an item this user has downloaded, built from the
/// download row — no server involved. `None` when there is no completed
/// local file, which leaves the question to the server.
///
/// This exists because playing a download asked the server first. The
/// player needs only the media-source id (subtitle URLs are keyed by it),
/// and fetching that from `/PlaybackInfo` meant a downloaded film would not
/// play offline: the call retried for seven seconds and failed, and the
/// file on disk was never opened.
///
/// The media-source id is the item id. A download never names a source —
/// the URL carries no `mediaSourceId` — so the server serves its default,
/// and Jellyfin gives an item's default source the item's own id.
///
/// TRACES: UR-002, UR-071 | DR-294 | UT-260
pub async fn local_playback_info(
&self,
item_id: &str,
) -> Result<Option<PlaybackInfo>, RepoError> {
let rows = self
.db_service
.query_many(
Query::with_params(
"SELECT file_path FROM downloads \
WHERE item_id = ? AND user_id = ? AND status = 'completed' \
AND file_path IS NOT NULL \
LIMIT 1",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(self.user_id.clone()),
],
),
|row| row.get::<_, String>(0),
)
.await
.map_err(|e| RepoError::Database { message: e })?;
Ok(rows.into_iter().next().map(|file_path| PlaybackInfo {
media_source_id: item_id.to_string(),
// No server session: nothing was negotiated, and a local file has no
// transcode job for a session id to name.
play_session_id: String::new(),
stream_url: file_path,
direct_play: true,
needs_transcoding: false,
}))
}
pub fn new(db_service: Arc<RusqliteService>, server_id: String, user_id: String) -> Self {
Self {
db_service,
@@ -2760,6 +2808,12 @@ mod tests {
}
fn create_test_db() -> Arc<RusqliteService> {
Arc::new(RusqliteService::new(create_test_conn()))
}
/// The raw connection behind [`create_test_db`], for tests that need to
/// hold its lock — standing in for a concurrent write.
fn create_test_conn() -> Arc<Mutex<Connection>> {
let conn = Connection::open_in_memory().unwrap();
// Enable foreign key constraints (they're disabled by default in SQLite)
@@ -2868,6 +2922,8 @@ mod tests {
CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
user_id TEXT,
file_path TEXT,
status TEXT NOT NULL,
file_size INTEGER
);
@@ -2934,10 +2990,267 @@ mod tests {
[],
).unwrap();
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
Arc::new(Mutex::new(conn))
}
/// Helper to create a test MediaItem
/// The reported bug: offline, a downloaded episode would not play. The
/// player found the file on disk, then asked the *server* for the item's
/// playback info — only to read its media-source id — and with no network
/// that call retried for seven seconds and failed, so playback never began.
/// A download that needs the internet to play is not a download.
///
/// Driven through the real `HybridRepository` against a server that refuses
/// connections, because the defect was the hybrid layer sending this call
/// only to the server.
///
/// TRACES: UR-002, UR-071 | DR-294 | UT-260
#[tokio::test]
async fn test_a_downloaded_item_gets_playback_info_without_the_server() {
let db_service = create_test_db();
for sql in [
"INSERT INTO downloads (item_id, user_id, file_path, status) \
VALUES ('ep-6', 'test-user', '/data/videos/S01E06.mp4', 'completed')",
// Still downloading: there is no local file to play yet.
"INSERT INTO downloads (item_id, user_id, file_path, status) \
VALUES ('ep-7', 'test-user', '/data/videos/S01E07.mp4', 'downloading')",
// Someone else's download is not this user's local copy.
"INSERT INTO downloads (item_id, user_id, file_path, status) \
VALUES ('ep-8', 'other-user', '/data/videos/S01E08.mp4', 'completed')",
] {
db_service.execute(Query::new(sql)).await.unwrap();
}
let offline = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
let local = OfflineRepository::new(
db_service,
"test-server".to_string(),
"test-user".to_string(),
);
// Port 9 on loopback: nothing listens, so every request is refused.
let online = crate::repository::OnlineRepository::new(
Arc::new(
crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
),
"http://127.0.0.1:9".to_string(),
"test-user".to_string(),
"test-token".to_string(),
);
let hybrid = crate::repository::HybridRepository::new(online, offline);
let started = std::time::Instant::now();
let info = hybrid
.get_playback_info("ep-6")
.await
.expect("a downloaded item must get playback info with no server");
assert!(
started.elapsed() < std::time::Duration::from_secs(1),
"answered locally, not after the network gave up: {:?}",
started.elapsed()
);
assert_eq!(info.stream_url, "/data/videos/S01E06.mp4");
assert!(info.direct_play && !info.needs_transcoding);
// Jellyfin's default media source shares the item's id, and a download
// is always of the default source (no mediaSourceId is requested).
assert_eq!(info.media_source_id, "ep-6");
// Not playable locally → still the server's question to answer.
for not_local in ["ep-7", "ep-8", "never-downloaded"] {
assert!(
local
.local_playback_info(not_local)
.await
.unwrap()
.is_none(),
"{not_local} has no completed local file for this user"
);
}
}
/// Next Up went only to the server, so offline it failed — and the TV
/// landing page loads it together with Continue Watching and Latest in one
/// `Promise.all`, so that one failure blanked the whole page ("Failed to
/// load TV sections: Offline") with its other rows sitting in the cache.
/// With the server unreachable, the answer is the cache's.
///
/// TRACES: UR-002 | DR-294 | UT-261
#[tokio::test]
async fn test_next_up_answers_from_the_cache_when_the_server_is_unreachable() {
let offline = OfflineRepository::new(
create_test_db(),
"test-server".to_string(),
"test-user".to_string(),
);
let online = crate::repository::OnlineRepository::new(
Arc::new(
crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
),
"http://127.0.0.1:9".to_string(),
"test-user".to_string(),
"test-token".to_string(),
);
let hybrid = crate::repository::HybridRepository::new(online, offline);
let next_up = hybrid.get_next_up_episodes(None, Some(12)).await;
assert!(
next_up.is_ok(),
"an unreachable server must not fail Next Up offline: {next_up:?}"
);
}
/// The reported bug: offline, "More info" on a downloaded show failed with
/// "Failed to load item". Its seasons were in the cache the whole time.
///
/// `get_items` gave the cache 100 ms and *discarded* a slower answer, then
/// waited on the server — which offline fails — and returned the server's
/// error. The cache is one SQLite connection behind one mutex, so any write
/// in progress (the catalog sync that starts at every launch, a download
/// finishing) pushes a read past 100 ms routinely. Every other cached query
/// already keeps a slow read alive and waits for it when the server fails;
/// `get_items`, which the series page calls for the show and each season,
/// did not.
///
/// TRACES: UR-002 | DR-294 | UT-263
#[tokio::test]
async fn test_get_items_waits_for_a_slow_cache_when_the_server_is_unreachable() {
let _guard = lock_catalog_browse();
set_include_catalog_browse(true);
let conn = create_test_conn();
let db_service = Arc::new(RusqliteService::new(Arc::clone(&conn)));
for sql in [
"INSERT INTO items (id, server_id, name, item_type, synced_at) \
VALUES ('series-1', 'test-server', 'Show', 'Series', '2026-01-01')",
"INSERT INTO items (id, server_id, parent_id, name, item_type, synced_at) \
VALUES ('season-1', 'test-server', 'series-1', 'Season 1', 'Season', '2026-01-01')",
] {
db_service.execute(Query::new(sql)).await.unwrap();
}
let offline = OfflineRepository::new(
db_service,
"test-server".to_string(),
"test-user".to_string(),
);
let online = crate::repository::OnlineRepository::new(
Arc::new(
crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
),
"http://127.0.0.1:9".to_string(),
"test-user".to_string(),
"test-token".to_string(),
);
let hybrid = crate::repository::HybridRepository::new(online, offline);
// Sanity: with nothing holding the database, the season is found. A
// failure here is the fixture, not the bug.
let quick = hybrid
.get_items("series-1", None)
.await
.expect("unlocked read");
assert_eq!(quick.items.len(), 1, "fixture: the season is cached");
// Now a write holds the connection for 300 ms — longer than the fast path.
let held = Arc::clone(&conn);
let writer = std::thread::spawn(move || {
let _lock = held.lock().unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
});
std::thread::sleep(std::time::Duration::from_millis(20));
let slow = hybrid.get_items("series-1", None).await;
writer.join().unwrap();
let slow = slow.expect("a slow cache must still answer when the server cannot");
assert_eq!(slow.items.len(), 1);
assert_eq!(slow.items[0].id, "season-1");
}
/// A hybrid repository whose server refuses every connection, over `conn`.
fn hybrid_without_server(conn: &Arc<Mutex<Connection>>) -> crate::repository::HybridRepository {
let offline = OfflineRepository::new(
Arc::new(RusqliteService::new(Arc::clone(conn))),
"test-server".to_string(),
"test-user".to_string(),
);
let online = crate::repository::OnlineRepository::new(
Arc::new(
crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
),
"http://127.0.0.1:9".to_string(),
"test-user".to_string(),
"test-token".to_string(),
);
crate::repository::HybridRepository::new(online, offline)
}
/// Hold the database for `ms`, as a concurrent write does.
fn hold_database(conn: &Arc<Mutex<Connection>>, ms: u64) -> std::thread::JoinHandle<()> {
let held = Arc::clone(conn);
let writer = std::thread::spawn(move || {
let _lock = held.lock().unwrap();
std::thread::sleep(std::time::Duration::from_millis(ms));
});
std::thread::sleep(std::time::Duration::from_millis(20));
writer
}
/// The library list is the first thing an offline launch shows, and it had
/// the same defect as `get_items`: a cache read slowed past 100 ms by a write
/// was discarded, the server then failed, and the list came back as a
/// network error with the libraries on disk.
///
/// TRACES: UR-002 | DR-294 | UT-263
#[tokio::test]
async fn test_libraries_wait_for_a_slow_cache_when_the_server_is_unreachable() {
let conn = create_test_conn();
conn.lock()
.unwrap()
.execute(
"INSERT INTO libraries (id, server_id, name, collection_type) \
VALUES ('lib-tv', 'test-server', 'Shows', 'tvshows')",
[],
)
.unwrap();
let hybrid = hybrid_without_server(&conn);
let writer = hold_database(&conn, 300);
let libs = hybrid.get_libraries().await;
writer.join().unwrap();
let libs = libs.expect("a slow cache must still answer when the server cannot");
assert_eq!(libs.len(), 1);
assert_eq!(libs[0].id, "lib-tv");
}
/// Search and favourites read only the cache — there is no server to fall
/// back from — so a 100 ms deadline on them just fails them whenever the
/// database is busy, online or not.
///
/// TRACES: UR-002 | DR-294 | UT-263
#[tokio::test]
async fn test_cache_only_reads_wait_out_a_busy_database() {
let conn = create_test_conn();
let hybrid = hybrid_without_server(&conn);
// Fixture sanity: with the database free both answer.
hybrid
.search_cache_only("anything", None)
.await
.expect("unlocked search");
let writer = hold_database(&conn, 300);
let search = hybrid.search_cache_only("anything", None).await;
writer.join().unwrap();
search.expect("a busy database delays a cache-only search, it must not fail it");
let writer = hold_database(&conn, 300);
let favourites = hybrid
.get_favorites_cache_only(crate::repository::SearchScope::All, None)
.await;
writer.join().unwrap();
favourites.expect("a busy database delays cache-only favourites, it must not fail them");
}
fn create_test_item(id: &str, name: &str, parent_id: Option<&str>) -> MediaItem {
MediaItem {
id: id.to_string(),
+38 -46
View File
@@ -2520,54 +2520,42 @@ impl MediaRepository for OnlineRepository {
// fine in itself, but it also means a mis-typed cap degrades silently.
// Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
// video copy is gated by `allowVideoStreamCopy`.
match quality {
"high" => {
params.push("videoBitRate=8000000".to_string());
params.push("maxHeight=1080".to_string());
params.push("audioBitRate=384000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
params.push("allowVideoStreamCopy=false".to_string());
}
"medium" => {
params.push("videoBitRate=4000000".to_string());
params.push("maxHeight=720".to_string());
params.push("audioBitRate=256000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
params.push("allowVideoStreamCopy=false".to_string());
}
"low" => {
params.push("videoBitRate=1500000".to_string());
params.push("maxHeight=480".to_string());
params.push("audioBitRate=128000".to_string());
match crate::download::presets::download_preset(quality) {
Some(preset) => {
params.push(format!("videoBitRate={}", preset.video_bit_rate));
params.push(format!("maxHeight={}", preset.max_height));
params.push(format!("audioBitRate={}", preset.audio_bit_rate));
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
params.push("allowVideoStreamCopy=false".to_string());
}
// "original" (and any unknown value) → direct, resumable copy —
// unless the audio in that copy is undecodable where the file will
// be played back. A download is watched with no server in reach, so
// it has to satisfy the same constraint DR-149 applies to streams:
// the webview `<video>` element renders video on both platforms and
// decodes none of AC-3/E-AC-3/DTS/TrueHD. Copying those bytes to
// disk is what made a downloaded film play offline as picture with
// no sound while the same film had sound when streamed.
// unless the audio in that copy is undecodable by the renderer that
// will play the file. A download is watched with no server in reach,
// so there is nothing to fall back to: copying a track the renderer
// cannot decode is what made a downloaded film play offline as
// picture with no sound while the same film had sound when streamed
// (DR-171).
//
// Only the *audio* is re-encoded. `allowVideoStreamCopy` keeps an
// h264 source's picture byte-for-byte, so "original" still means
// original quality, and no bitrate or resolution cap is added. A
// source the webview could not have rendered anyway (HEVC) is
// re-encoded to h264 as a side effect, which is the only form of it
// that would have played.
// "The renderer" is DR-234's per-platform answer, not the webview's
// list. On Android that is ExoPlayer — the only video renderer there
// since DR-293 removed the webview path — which decodes the device's
// own codecs plus AC-3/E-AC-3/DTS/TrueHD through the FFmpeg
// extension. So on Android every `original` download is a
// `Static=true` copy: fast, resumable (HTTP 206), and the real file.
// Judging against the webview's list instead turned most films into a
// server transcode — generated as it is sent, no `Content-Length`,
// `Range` ignored — measured at ~1 MB/s against 14.5 MB/s for the
// copy, and restarting from zero on every network blip.
//
// The cost of the transcode is that the response is no longer
// range-resumable, which is exactly why this is decided per item
// rather than applied to every `original` download.
// On Linux the webview still draws video, so the renderer's list *is*
// the webview's and the transcode below still applies there. Only the
// *audio* is re-encoded: `allowVideoStreamCopy` keeps an h264 source's
// picture byte-for-byte, so "original" still means original quality.
//
// TRACES: UR-071, UR-004 | DR-171 | UT-166
_ => match source_audio_codec {
Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
// TRACES: UR-071, UR-004 | DR-171, DR-293 | UT-166
None => match source_audio_codec {
Some(codec) if !super::device_profile::renderer_can_decode_audio(codec) => {
params.push("videoCodec=h264".to_string());
params.push("allowVideoStreamCopy=true".to_string());
params.push("audioCodec=aac".to_string());
@@ -3747,13 +3735,17 @@ mod tests {
/// holds audio this device cannot decode.
///
/// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
/// track included, and video plays through the webview `<video>` element on
/// both platforms — which decodes none of them. Streaming already knows this
/// (DR-149 forces a transcode over the server's own direct-play offer); the
/// download path did not, so a downloaded film played offline as picture with
/// no sound while the very same film had sound when streamed.
/// track included. Where the webview `<video>` element renders video —
/// Linux, which is where this test runs — none of them decode. Streaming
/// already knew this (DR-149); the download path did not, so a downloaded
/// film played offline as picture with no sound.
///
/// TRACES: UR-071, UR-004 | DR-171 | UT-166
/// On Android the renderer is ExoPlayer with the FFmpeg extension, which
/// decodes all of these, so the same call there yields a `Static=true` copy
/// (DR-293). The policy is `renderer_can_decode_audio`; this test pins its
/// webview half.
///
/// TRACES: UR-071, UR-004 | DR-171, DR-293 | UT-166
#[test]
fn test_video_download_url_original_transcodes_undecodable_audio() {
let repo = create_test_repository();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "JellyTau",
"version": "0.12.1",
"version": "0.13.0",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
+7 -1
View File
@@ -2886,7 +2886,13 @@ usesWebviewAudio: boolean;
* beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
* compositing), so it stays on the HTML5 element.
*/
supportsNativeVideo: boolean }
supportsNativeVideo: boolean;
/**
* True when the user may send video to the webview element instead of the
* native renderer the frontend offers the switch only then, and honours
* the stored preference only then. See [`webview_video_fallback`].
*/
webviewVideoFallback: boolean }
/**
* Playback information
*/
@@ -1,5 +1,6 @@
<script lang="ts">
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
import { describeProgress, formatBytes } from "./downloadProgress";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("DownloadItem");
@@ -10,20 +11,8 @@
let { download }: Props = $props();
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
function formatProgress(): string {
if (!download.fileSize) {
return formatBytes(download.bytesDownloaded);
}
return `${formatBytes(download.bytesDownloaded)} / ${formatBytes(download.fileSize)}`;
}
// Exact, estimated, or unknown total — see downloadProgress.ts (DR-290).
const view = $derived(describeProgress(download));
function getStatusColor(): string {
switch (download.status) {
@@ -201,15 +190,24 @@
<!-- Progress Bar (for active/paused downloads) -->
{#if download.status === "downloading" || download.status === "paused"}
<div class="w-full bg-gray-700 rounded-full h-2 mb-2">
<div
class="h-2 rounded-full transition-all duration-300 {getStatusColor()}"
style="width: {download.progress * 100}%"
></div>
<div class="w-full bg-gray-700 rounded-full h-2 mb-2 overflow-hidden">
{#if view.kind === "indeterminate"}
<!-- No total to measure against: a moving band, not a bar stuck at 0% -->
<div
class="h-2 w-1/3 rounded-full {getStatusColor()} {download.status === 'downloading'
? 'animate-indeterminate'
: ''}"
></div>
{:else}
<div
class="h-2 rounded-full transition-all duration-300 {getStatusColor()}"
style="width: {view.percent}%"
></div>
{/if}
</div>
<div class="flex items-center justify-between text-xs text-gray-400">
<span>{Math.round(download.progress * 100)}%</span>
<span>{formatProgress()}</span>
<span>{view.percentLabel}</span>
<span>{view.label}</span>
</div>
{:else if download.status === "completed"}
<p class="text-xs text-gray-400">{formatBytes(download.bytesDownloaded)}</p>
@@ -365,3 +363,18 @@
</div>
</div>
</div>
<style>
/* A band sweeping the track: "still moving, size unknown". */
@keyframes indeterminate {
from {
transform: translateX(-100%);
}
to {
transform: translateX(300%);
}
}
.animate-indeterminate {
animation: indeterminate 1.4s ease-in-out infinite;
}
</style>
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { describeProgress } from "./downloadProgress";
const base = {
status: "downloading" as const,
progress: 0,
bytesDownloaded: 0,
fileSize: undefined as number | undefined,
fileSizeEstimated: false,
};
// TRACES: UR-071 | DR-290 | UT-254
describe("describeProgress", () => {
it("shows an indeterminate bar, not 0%, when the size is unknown", () => {
// A transcode has no Content-Length and (before the estimate) no total:
// 200 MB in, the bar read "0%". That is not progress information.
const view = describeProgress({ ...base, bytesDownloaded: 200 * 1024 * 1024 });
expect(view.kind).toBe("indeterminate");
expect(view.percent).toBeNull();
expect(view.label).toBe("200.0 MB");
});
it("marks an estimated total as approximate", () => {
const view = describeProgress({
...base,
progress: 0.42,
bytesDownloaded: 420,
fileSize: 1000,
fileSizeEstimated: true,
});
expect(view.kind).toBe("estimated");
expect(view.percent).toBe(42);
expect(view.percentLabel).toBe("~42%");
expect(view.label).toBe("420 B / ~1000 B");
});
it("reports an exact total plainly", () => {
const view = describeProgress({
...base,
progress: 0.5,
bytesDownloaded: 512,
fileSize: 1024,
});
expect(view.kind).toBe("exact");
expect(view.percent).toBe(50);
expect(view.percentLabel).toBe("50%");
expect(view.label).toBe("512 B / 1.0 KB");
});
it("keeps a paused download's bar where it stopped", () => {
const view = describeProgress({
...base,
status: "paused",
progress: 0.25,
bytesDownloaded: 256,
fileSize: 1024,
});
expect(view.kind).toBe("exact");
expect(view.percent).toBe(25);
});
});
@@ -0,0 +1,64 @@
/**
* What the progress bar on a download row should say.
*
* Extracted from `DownloadItem.svelte` so the three cases can be unit-tested:
* an exact total (the server sent `Content-Length`), an estimated one (a
* transcode sends none, so the backend predicted it from the source), and no
* total at all which used to render as an empty bar reading "0%" for the
* whole of a transcode download. Progress with no denominator is not 0%; it
* is unknown, and the bar says so.
*
* TRACES: UR-071 | DR-290 | UT-254
*/
export interface ProgressSource {
status: "pending" | "downloading" | "completed" | "failed" | "paused";
/** 0..1 as the backend reports it; already capped short of 1 when estimated. */
progress: number;
bytesDownloaded: number;
fileSize?: number;
/** `fileSize` is the backend's prediction, not the server's word. */
fileSizeEstimated?: boolean;
}
export type ProgressKind = "exact" | "estimated" | "indeterminate";
export interface ProgressView {
kind: ProgressKind;
/** Whole percent for the bar width; `null` when there is nothing to measure against. */
percent: number | null;
/** "42%" / "~42%"; empty when indeterminate. */
percentLabel: string;
/** "512 B / 1.0 KB", "420 B / ~1000 B", or just the bytes so far. */
label: string;
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${(bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1)} ${sizes[i]}`;
}
export function describeProgress(d: ProgressSource): ProgressView {
const downloaded = formatBytes(d.bytesDownloaded);
if (!d.fileSize) {
return { kind: "indeterminate", percent: null, percentLabel: "", label: downloaded };
}
const percent = Math.round(Math.min(Math.max(d.progress, 0), 1) * 100);
if (d.fileSizeEstimated) {
return {
kind: "estimated",
percent,
percentLabel: `~${percent}%`,
label: `${downloaded} / ~${formatBytes(d.fileSize)}`,
};
}
return {
kind: "exact",
percent,
percentLabel: `${percent}%`,
label: `${downloaded} / ${formatBytes(d.fileSize)}`,
};
}
+163 -149
View File
@@ -57,167 +57,181 @@
const episodeNumber = $derived(episode.indexNumber || 0);
</script>
<button
bind:this={buttonRef}
type="button"
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused
? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]'
: current
? 'ring-2 ring-yellow-400 bg-[var(--color-surface)]'
: ''}"
{onclick}
>
<!-- Thumbnail -->
<div
class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]"
<!-- Narrow rows stack the title under a full-width thumbnail instead of
squeezing it beside a fixed 160px one. A container query, not a viewport
breakpoint, so it follows the row's own width. -->
<div class="@container">
<button
bind:this={buttonRef}
type="button"
class="group/row flex flex-col @md:flex-row gap-3 @md:gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused
? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]'
: current
? 'ring-2 ring-yellow-400 bg-[var(--color-surface)]'
: ''}"
{onclick}
>
<CachedImage
itemId={episode.id}
imageType="Primary"
tag={episode.imageId}
maxWidth={320}
alt={episode.name}
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
/>
<!-- Hover overlay with play icon -->
<!-- Thumbnail -->
<div
class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center"
class="relative flex-shrink-0 w-full @md:w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]"
>
<div class="opacity-0 group-hover/row:opacity-100 transition-opacity">
<div
class="w-10 h-10 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center"
>
<svg class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
<CachedImage
itemId={episode.id}
imageType="Primary"
tag={episode.imageId}
maxWidth={640}
alt={episode.name}
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
/>
<!-- Hover overlay with play icon -->
<div
class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center"
>
<div class="opacity-0 group-hover/row:opacity-100 transition-opacity">
<div
class="w-10 h-10 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center"
>
<svg class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
</div>
<!-- Progress bar -->
{#if progress() > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress()}%"></div>
</div>
{/if}
<!-- Download indicator -->
{#if isDownloaded || isDownloading}
<div
class="absolute bottom-2 right-2"
title={isDownloaded ? "Downloaded" : "Downloading..."}
>
{#if isDownloaded}
<div
class="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center shadow-lg"
>
<svg
class="w-3 h-3 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4v12m0 0l-4-4m4 4l4-4"
/>
</svg>
</div>
{:else if isDownloading}
<div class="w-5 h-5 relative">
<svg class="w-5 h-5 -rotate-90" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
r="10"
fill="rgba(0,0,0,0.6)"
stroke="rgba(255,255,255,0.3)"
stroke-width="2"
/>
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke="#3b82f6"
stroke-width="2"
stroke-dasharray={2 * Math.PI * 10}
stroke-dashoffset={2 * Math.PI * 10 * (1 - downloadProgress)}
stroke-linecap="round"
class="transition-all duration-300"
/>
</svg>
</div>
{/if}
</div>
{/if}
</div>
<!-- Progress bar -->
{#if progress() > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress()}%"></div>
</div>
{/if}
<!-- Download indicator -->
{#if isDownloaded || isDownloading}
<div class="absolute bottom-2 right-2" title={isDownloaded ? "Downloaded" : "Downloading..."}>
{#if isDownloaded}
<div class="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
<svg
class="w-3 h-3 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
<!-- Episode info -->
<div class="flex-1 min-w-0 @md:py-1">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<!-- Episode number and title -->
<div class="flex items-center gap-2">
<span class="text-[var(--color-jellyfin)] font-semibold text-sm">
{episodeNumber}.
</span>
<h3
class="text-white font-medium line-clamp-2 @md:truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
{truncateMiddle(episode.name, 56)}
</h3>
{#if current}
<span
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
>
Up next
</span>
{/if}
<!-- Played indicator -->
{#if episode.userData?.isPlayed}
<svg
class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</div>
{:else if isDownloading}
<div class="w-5 h-5 relative">
<svg class="w-5 h-5 -rotate-90" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
r="10"
fill="rgba(0,0,0,0.6)"
stroke="rgba(255,255,255,0.3)"
stroke-width="2"
/>
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke="#3b82f6"
stroke-width="2"
stroke-dasharray={2 * Math.PI * 10}
stroke-dashoffset={2 * Math.PI * 10 * (1 - downloadProgress)}
stroke-linecap="round"
class="transition-all duration-300"
/>
</svg>
</div>
{/if}
</div>
{/if}
</div>
<!-- Episode info -->
<div class="flex-1 min-w-0 py-1">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<!-- Episode number and title -->
<div class="flex items-center gap-2">
<span class="text-[var(--color-jellyfin)] font-semibold text-sm">
{episodeNumber}.
</span>
<h3
class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors"
>
{truncateMiddle(episode.name, 56)}
</h3>
{#if current}
<span
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
>
Up next
<!-- Overview -->
{#if episode.overview}
<p class="text-gray-400 text-sm mt-1 line-clamp-2">
{episode.overview}
</p>
{/if}
</div>
<!-- Duration and Download -->
<div class="flex items-center gap-2 flex-shrink-0">
{#if duration}
<span class="text-gray-500 text-sm">
{duration}
</span>
{/if}
<!-- Played indicator -->
{#if episode.userData?.isPlayed}
<svg
class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{/if}
</div>
<!-- Overview -->
{#if episode.overview}
<p class="text-gray-400 text-sm mt-1 line-clamp-2">
{episode.overview}
</p>
{/if}
</div>
<!-- Duration and Download -->
<div class="flex items-center gap-2 flex-shrink-0">
{#if duration}
<span class="text-gray-500 text-sm">
{duration}
</span>
{/if}
<!-- Watched toggle - stop propagation to prevent episode play -->
<div onclick={(e) => e.stopPropagation()} role="none">
<WatchedToggleButton
itemId={episode.id}
watched={episode.userData?.isPlayed ?? false}
scope="episode"
size="sm"
onChanged={onWatchedChanged}
/>
</div>
<!-- Download button - stop propagation to prevent episode play -->
<div onclick={(e) => e.stopPropagation()} role="none">
<VideoDownloadButton
itemId={episode.id}
itemName={episode.name}
seriesName={episode.seriesName ?? undefined}
seasonName={episode.seasonName ?? undefined}
episodeNumber={episode.indexNumber ?? undefined}
seasonNumber={episode.parentIndexNumber ?? undefined}
size="sm"
/>
<!-- Watched toggle - stop propagation to prevent episode play -->
<div onclick={(e) => e.stopPropagation()} role="none">
<WatchedToggleButton
itemId={episode.id}
watched={episode.userData?.isPlayed ?? false}
scope="episode"
size="sm"
onChanged={onWatchedChanged}
/>
</div>
<!-- Download button - stop propagation to prevent episode play -->
<div onclick={(e) => e.stopPropagation()} role="none">
<VideoDownloadButton
itemId={episode.id}
itemName={episode.name}
seriesName={episode.seriesName ?? undefined}
seasonName={episode.seasonName ?? undefined}
episodeNumber={episode.indexNumber ?? undefined}
seasonNumber={episode.parentIndexNumber ?? undefined}
size="sm"
/>
</div>
</div>
</div>
</div>
</div>
</button>
</button>
</div>
@@ -0,0 +1,154 @@
/**
* The list view must grey and offer to queue server-only media exactly as the
* grid does. It did neither: switching a library to list view offline turned
* every non-downloaded item back into a normal, tappable row that plays
* nothing.
*
* TRACES: UR-052 | DR-292 | UT-258
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/svelte";
const h = vi.hoisted(() => {
function shim<T>(initial: T) {
let value = initial;
const subs = new Set<(v: T) => void>();
return {
set(v: T) {
value = v;
subs.forEach((fn) => fn(value));
},
subscribe(fn: (v: T) => void) {
subs.add(fn);
fn(value);
return () => subs.delete(fn);
},
};
}
return {
isConnectedStore: shim(true),
showServerCatalogStore: shim(false),
downloadsStore: shim({ downloads: {} as Record<string, unknown> }),
deviceContentIdsStore: shim(new Set<string>()),
downloadItem: vi.fn(async () => 1),
getUserId: vi.fn(() => "user-1"),
};
});
vi.mock("$lib/stores/connectivity", () => ({
isConnected: { subscribe: h.isConnectedStore.subscribe },
}));
vi.mock("$lib/services/offlineCatalog", () => ({
showServerCatalog: { subscribe: h.showServerCatalogStore.subscribe },
}));
vi.mock("$lib/services/downloadedCatalog", () => ({
deviceContentIds: { subscribe: h.deviceContentIdsStore.subscribe },
}));
vi.mock("$lib/stores/downloads", () => ({
downloads: { subscribe: h.downloadsStore.subscribe, downloadItem: h.downloadItem },
}));
vi.mock("$lib/stores/auth", () => ({
auth: { getUserId: h.getUserId },
}));
vi.mock("$lib/components/common/CachedImage.svelte", async () => ({
default: (await import("./__mocks__/StubImage.svelte")).default,
}));
import LibraryListView from "./LibraryListView.svelte";
const movie = {
id: "movie-1",
name: "Some Film",
type: "Movie" as const,
serverId: "server-1",
productionYear: 1999,
};
const album = {
id: "album-1",
name: "Some Album",
type: "MusicAlbum" as const,
serverId: "server-1",
};
describe("LibraryListView server-only rows", () => {
beforeEach(() => {
vi.clearAllMocks();
h.isConnectedStore.set(true);
h.showServerCatalogStore.set(false);
h.downloadsStore.set({ downloads: {} });
h.deviceContentIdsStore.set(new Set());
});
it("shows no queue control while online", () => {
render(LibraryListView, { props: { items: [movie] } });
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
it("offers to queue a server-only row when offline with the reveal on", () => {
h.isConnectedStore.set(false);
h.showServerCatalogStore.set(true);
render(LibraryListView, { props: { items: [movie] } });
expect(screen.getByLabelText(/Queue download for Some Film/i)).toBeTruthy();
});
it("queues the row's item on click", async () => {
h.isConnectedStore.set(false);
h.showServerCatalogStore.set(true);
render(LibraryListView, { props: { items: [movie] } });
await fireEvent.click(screen.getByLabelText(/Queue download for Some Film/i));
expect(h.downloadItem).toHaveBeenCalledTimes(1);
const args = h.downloadItem.mock.calls[0] as unknown as unknown[];
expect(args[0]).toBe("movie-1");
expect(args[1]).toBe("user-1");
});
it("makes a server-only row inert: tapping it cannot start playback", async () => {
h.isConnectedStore.set(false);
h.showServerCatalogStore.set(true);
const onItemClick = vi.fn();
render(LibraryListView, { props: { items: [movie], onItemClick } });
await fireEvent.click(screen.getByText("Some Film"));
expect(onItemClick).not.toHaveBeenCalled();
});
it("leaves a downloaded row alone", () => {
h.isConnectedStore.set(false);
h.showServerCatalogStore.set(true);
h.downloadsStore.set({
downloads: { "movie-1": { itemId: "movie-1", status: "completed", progress: 1 } },
});
render(LibraryListView, { props: { items: [movie] } });
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
it("leaves a container alone when the device holds its children", () => {
h.isConnectedStore.set(false);
h.showServerCatalogStore.set(true);
h.deviceContentIdsStore.set(new Set(["album-1"]));
render(LibraryListView, { props: { items: [album] } });
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
it("shows a Queued badge instead of the button for a pending row", () => {
h.isConnectedStore.set(false);
h.showServerCatalogStore.set(true);
h.downloadsStore.set({
downloads: { "movie-1": { itemId: "movie-1", status: "pending", progress: 0 } },
});
render(LibraryListView, { props: { items: [movie] } });
expect(screen.getByText(/Queued/i)).toBeTruthy();
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
});
@@ -1,10 +1,26 @@
<!--
List counterpart of the grid's MediaCard. The two must agree about what a row
offline means: before DR-292 this view knew nothing about the offline catalog
reveal, so a library switched to list view showed every server-only item as a
normal, tappable row that plays nothing.
TRACES: UR-052 | DR-292
-->
<script lang="ts">
import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { downloads } from "$lib/stores/downloads";
import { isConnected } from "$lib/stores/connectivity";
import { showServerCatalog } from "$lib/services/offlineCatalog";
import { deviceContentIds } from "$lib/services/downloadedCatalog";
import { isServerOnly } from "$lib/utils/serverOnly";
import { queueOfflineDownload } from "$lib/services/queueOfflineDownload";
import { formatDuration } from "$lib/utils/duration";
import { createLogger } from "$lib/utils/logger";
import CachedImage from "$lib/components/common/CachedImage.svelte";
const log = createLogger("LibraryListView");
interface Props {
items: (MediaItem | Library)[];
showProgress?: boolean;
@@ -18,6 +34,33 @@
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
}
/** Same rule the grid card applies — see $lib/utils/serverOnly. */
function serverOnly(item: MediaItem | Library): boolean {
const info = getDownloadInfo(item.id);
return isServerOnly({
isMediaItem: "type" in item,
isConnected: $isConnected,
revealServerCatalog: $showServerCatalog,
isDownloaded: info?.status === "completed",
isActivelyDownloading: info?.status === "downloading",
hasDeviceContent: $deviceContentIds.has(item.id),
});
}
let queueError = $state<string | null>(null);
async function queueForDownload(e: Event, item: MediaItem | Library) {
e.stopPropagation();
if (!("type" in item)) return;
try {
queueError = null;
await queueOfflineDownload(item);
} catch (err) {
log.error("Failed to queue download:", err);
queueError = err instanceof Error ? err.message : "Failed to queue";
}
}
function getImageTag(item: MediaItem | Library): string | undefined {
return "imageId" in item
? (item.imageId ?? undefined)
@@ -78,12 +121,20 @@
{@const isDownloaded = downloadInfo?.status === "completed"}
{@const isDownloading =
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
{@const isQueued = downloadInfo?.status === "pending"}
{@const rowServerOnly = serverOnly(item)}
<button
type="button"
<!-- A server-only row is not a button: there is nothing to open offline,
and the queue control it carries cannot live inside one. -->
<svelte:element
this={rowServerOnly ? "div" : "button"}
type={rowServerOnly ? undefined : "button"}
role={rowServerOnly ? "group" : undefined}
data-grid-index={index}
onclick={() => onItemClick?.(item)}
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--color-surface)] transition-colors group"
onclick={rowServerOnly ? undefined : () => onItemClick?.(item)}
class="w-full flex items-center gap-3 p-2 rounded-lg transition-colors group {rowServerOnly
? 'opacity-60'
: 'hover:bg-[var(--color-surface)]'}"
>
<!-- Track number or index -->
<span class="text-gray-500 w-6 text-right text-sm flex-shrink-0">
@@ -103,9 +154,11 @@
class="w-full h-full object-cover"
/>
<!-- Play overlay on hover -->
<!-- Play overlay on hover (never on an inert server-only row) -->
<div
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
class="absolute inset-0 bg-black/50 opacity-0 transition-opacity flex items-center justify-center {rowServerOnly
? ''
: 'group-hover:opacity-100'}"
>
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
@@ -123,7 +176,9 @@
<!-- Title & Subtitle -->
<div class="flex-1 min-w-0 text-left">
<p
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
class="text-sm font-medium text-white truncate transition-colors {rowServerOnly
? ''
: 'group-hover:text-[var(--color-jellyfin)]'}"
>
{truncateMiddle(item.name, 56)}
</p>
@@ -192,6 +247,39 @@
{#if duration}
<span class="text-xs text-gray-400 flex-shrink-0">{duration}</span>
{/if}
</button>
<!-- Server-only: queue for the next reconnect, mirroring the grid card.
A row already queued shows the badge instead of the button. -->
{#if rowServerOnly}
{#if isQueued}
<span
class="text-[10px] font-medium bg-white/10 text-gray-300 px-2 py-0.5 rounded-full flex-shrink-0"
title="Queued — will download on reconnect">Queued</span
>
{:else}
<button
type="button"
onclick={(e) => queueForDownload(e, item)}
class="w-8 h-8 rounded-full bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/90 flex items-center justify-center flex-shrink-0 transition-colors"
title="Queue download for next connection"
aria-label="Queue download for {item.name}"
>
<svg
class="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
</button>
{/if}
{/if}
</svelte:element>
{/each}
{#if queueError}
<p class="text-xs text-red-400 px-2">{queueError}</p>
{/if}
</div>
@@ -24,12 +24,20 @@ const h = vi.hoisted(() => {
isConnectedStore: shim(true),
showServerCatalogStore: shim(false),
downloadsStore: shim({ downloads: {} as Record<string, any> }),
deviceContentIdsStore: shim(new Set<string>()),
downloadItem: vi.fn(async () => 1),
getUserId: vi.fn(() => "user-1"),
};
});
const { isConnectedStore, showServerCatalogStore, downloadsStore, downloadItem, getUserId } = h;
const {
isConnectedStore,
showServerCatalogStore,
downloadsStore,
deviceContentIdsStore,
downloadItem,
getUserId,
} = h;
vi.mock("$lib/stores/connectivity", () => ({
isConnected: { subscribe: h.isConnectedStore.subscribe },
@@ -39,6 +47,10 @@ vi.mock("$lib/services/offlineCatalog", () => ({
showServerCatalog: { subscribe: h.showServerCatalogStore.subscribe },
}));
vi.mock("$lib/services/downloadedCatalog", () => ({
deviceContentIds: { subscribe: h.deviceContentIdsStore.subscribe },
}));
vi.mock("$lib/stores/downloads", () => ({
downloads: { subscribe: h.downloadsStore.subscribe, downloadItem: h.downloadItem },
}));
@@ -69,6 +81,7 @@ describe("MediaCard server-only (offline browse & queue)", () => {
isConnectedStore.set(true);
showServerCatalogStore.set(false);
downloadsStore.set({ downloads: {} });
deviceContentIdsStore.set(new Set());
});
it("shows no queue button while online", () => {
@@ -117,6 +130,23 @@ describe("MediaCard server-only (offline browse & queue)", () => {
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
// An album carries no download row of its own — its tracks do — so the card
// greyed out a fully downloaded album and offered to queue what was already
// on the device. The backend's disk-usage map answers for containers too.
// TRACES: UR-052 | DR-292 | UT-257
it("does not grey a container whose children are on the device", () => {
isConnectedStore.set(false);
showServerCatalogStore.set(true);
deviceContentIdsStore.set(new Set(["album-1"]));
render(MediaCard, {
props: {
item: { id: "album-1", name: "Album X", type: "MusicAlbum", serverId: "server-1" },
},
});
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
it("does not grey out a completed download", () => {
isConnectedStore.set(false);
showServerCatalogStore.set(true);
+21 -25
View File
@@ -1,11 +1,13 @@
<!-- TRACES: UR-037, UR-051, UR-052, UR-068 | DR-042, DR-068, DR-078, DR-119 -->
<!-- TRACES: UR-037, UR-051, UR-052, UR-068 | DR-042, DR-068, DR-078, DR-119, DR-292 -->
<script lang="ts">
import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { downloads } from "$lib/stores/downloads";
import { isConnected } from "$lib/stores/connectivity";
import { showServerCatalog } from "$lib/services/offlineCatalog";
import { auth } from "$lib/stores/auth";
import { deviceContentIds } from "$lib/services/downloadedCatalog";
import { isServerOnly as computeIsServerOnly } from "$lib/utils/serverOnly";
import { queueOfflineDownload } from "$lib/services/queueOfflineDownload";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
@@ -143,11 +145,21 @@
const isQueued = $derived(downloadInfo?.status === "pending");
// Actively transferring (as opposed to merely queued/pending for reconnect).
const isActivelyDownloading = $derived(downloadInfo?.status === "downloading");
// "Server only" = offline, reveal on, and not already downloaded or actively
// transferring. A `pending` (queued-for-reconnect) item stays server-only so
// it can show the Queued badge in place of the queue button.
// "Server only" = offline, reveal on, and nothing on the device behind this
// card — neither its own download nor, for a container, its children's (the
// album case: only tracks carry download rows). A `pending`
// (queued-for-reconnect) item stays server-only so it can show the Queued
// badge in place of the queue button. Shared with the list view; see
// $lib/utils/serverOnly.
const isServerOnly = $derived(
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading,
computeIsServerOnly({
isMediaItem,
isConnected: $isConnected,
revealServerCatalog: $showServerCatalog,
isDownloaded,
isActivelyDownloading,
hasDeviceContent: $deviceContentIds.has(item.id),
}),
);
// The heart is about an item, so libraries never get one, and a greyed
@@ -165,29 +177,13 @@
async function queueForDownload(e: Event) {
e.stopPropagation();
if (!isMediaItem) return;
const media = item as MediaItem;
const userId = auth.getUserId();
if (!userId) {
queueError = "Not signed in";
return;
}
try {
queueError = null;
// Derive a sensible on-disk path; the backend heals stream_url on reconnect.
const filePath = `downloads/${media.id}`;
await downloads.downloadItem(
media.id,
userId,
filePath,
undefined,
undefined,
media.name,
media.artists?.join(", ") ?? undefined,
media.albumName ?? undefined,
);
await queueOfflineDownload(item as MediaItem);
} catch (err) {
log.error("Failed to queue download:", err);
queueError = "Failed to queue";
queueError =
err instanceof Error && err.message === "Not signed in" ? err.message : "Failed to queue";
}
}
@@ -64,14 +64,10 @@
return;
}
const repo = auth.getRepository();
const handle = auth.getRepository().getHandle();
log.debug("🎬 Starting video download for item:", itemId, "quality:", quality);
// Get stream URL based on quality
const streamUrl = await repo.getVideoDownloadUrl(itemId, quality);
log.debug(" Stream URL obtained");
// Get target directory
const targetDir = await commands.storageGetPath();
@@ -109,9 +105,12 @@
// Pin the item metadata
await downloads.pinItem(itemId);
// Actually start the download
await commands.startDownload(downloadId, streamUrl, targetDir);
log.debug(" Download started");
// Resolve the URL and start it the same way the series/season buttons
// do: Rust picks the transcode from the row's preset, predicts the size
// so the bar has a total when the response has no length (DR-290), and
// the queue pump starts it when a slot is free.
await commands.enqueueVideoDownloads(handle, [downloadId], targetDir);
log.debug(" Download enqueued");
} catch (error) {
log.error("Failed to start video download:", error);
} finally {
@@ -44,6 +44,18 @@ vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
};
});
// The webview path only exists where Rust offers a fallback from the native
// renderer — beside mpv native video on Linux; never on Android since DR-293,
// where a stored "off" is ignored. These tests guard that path's scrubbing, so
// they declare a platform that has it.
vi.mock("$lib/services/playbackCapabilities", () => ({
getPlaybackCapabilities: async () => ({
usesWebviewAudio: false,
supportsNativeVideo: true,
webviewVideoFallback: true,
}),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (channel: string, handler: any) => {
channelHandlers[channel] = handler;
+9 -3
View File
@@ -41,7 +41,8 @@
type Html5ElementBridge,
} from "$lib/player/adapters";
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import { experimentalNativeVideo, nativeVideoWanted } from "$lib/stores/nativeVideo";
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
import {
enableNativeVideoCompositing,
disableNativeVideoCompositing,
@@ -1020,7 +1021,12 @@
// it is off we must also stop the native backend that player_play_item
// just started, or ExoPlayer and the <video> element both decode the
// same stream and the audio doubles.
if (!useHtml5Element && !$experimentalNativeVideo) {
// The stored choice only counts where Rust says a webview fallback
// exists — never on Android, where the webview would play an
// original-file download silent (DR-293).
const { webviewVideoFallback } = await getPlaybackCapabilities();
const wantNative = nativeVideoWanted($experimentalNativeVideo, webviewVideoFallback);
if (!useHtml5Element && !wantNative) {
log.debug("Native backend available but experimentalNativeVideo is off - using HTML5");
useHtml5Element = true;
try {
@@ -1077,7 +1083,7 @@
bridge: adapterBridge,
// useHtml5Element is already the resolved decision above, so the
// flag has had its say; pass it through for the invariant check.
experimentalNativeVideo: $experimentalNativeVideo,
experimentalNativeVideo: wantNative,
});
// No-op for the native adapter, which owns no DOM element.
playerAdapter.attach(videoElement);
@@ -47,6 +47,18 @@ vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
};
});
// The webview path only exists where Rust offers a fallback from the native
// renderer — beside mpv native video on Linux; never on Android since DR-293,
// where a stored "off" is ignored. These tests guard that path's scrubbing, so
// they declare a platform that has it.
vi.mock("$lib/services/playbackCapabilities", () => ({
getPlaybackCapabilities: async () => ({
usesWebviewAudio: false,
supportsNativeVideo: true,
webviewVideoFallback: true,
}),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (channel: string, handler: any) => {
channelHandlers[channel] = handler;
+16
View File
@@ -112,6 +112,22 @@ function createDownloadedCatalogStore() {
export const downloadedCatalog = createDownloadedCatalogStore();
/**
* Every item id the device holds bytes for playable leaves *and* the
* containers above them, as the backend's disk-usage map reports them.
*
* This is what stops the offline browse greying out a fully downloaded album:
* only a leaf (Audio, Movie, Episode) ever has a download row of its own, so
* asking the downloads store about an album id always answered "no". Which ids
* are containers, and which children roll up into them, stays a Rust question
* (`get_download_disk_usage`); the frontend only reads membership.
*
* Refreshed with the rest of the catalog see `downloadedCatalog.refresh()`.
*
* TRACES: UR-052, UR-056 | DR-292
*/
export const deviceContentIds = derived(downloadedCatalog, ($c) => new Set(Object.keys($c.sizes)));
export const downloadedLibraries = derived(downloadedCatalog, ($c) => $c.libraries);
export const downloadedDeviceTotal = derived(downloadedCatalog, ($c) => $c.deviceTotalBytes);
export const downloadedItemCount = derived(downloadedCatalog, ($c) => $c.itemCount);
+8
View File
@@ -22,6 +22,12 @@ export interface PlaybackCapabilities {
usesWebviewAudio: boolean;
/** Video can render on a native surface behind a transparent webview. */
supportsNativeVideo: boolean;
/**
* The user may send video to the webview element instead of the native
* renderer. False on Android, where ExoPlayer is the only video renderer
* (DR-293). Rust decides; see `webview_video_fallback`.
*/
webviewVideoFallback: boolean;
}
/**
@@ -33,6 +39,7 @@ export interface PlaybackCapabilities {
const FALLBACK: PlaybackCapabilities = {
usesWebviewAudio: false,
supportsNativeVideo: false,
webviewVideoFallback: false,
};
let cached: PlaybackCapabilities | null = null;
@@ -52,6 +59,7 @@ export async function getPlaybackCapabilities(): Promise<PlaybackCapabilities> {
cached = {
usesWebviewAudio: !!caps?.usesWebviewAudio,
supportsNativeVideo: !!caps?.supportsNativeVideo,
webviewVideoFallback: !!caps?.webviewVideoFallback,
};
return cached;
} catch (err) {
+33
View File
@@ -0,0 +1,33 @@
/**
* Queue a server-only item for download on the next reconnect.
*
* Offline this only persists a `pending` downloads row with no `stream_url`;
* the reconnect handler resolves the URL and the pump starts it (see
* `offlineCatalog`). Shared by the grid card and the list row so the two
* surfaces queue identically the list view previously had no way to queue at
* all.
*
* TRACES: UR-052 | DR-292
*/
import type { MediaItem } from "$lib/api/types";
import { downloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth";
export async function queueOfflineDownload(item: MediaItem): Promise<void> {
const userId = auth.getUserId();
if (!userId) throw new Error("Not signed in");
// A sensible on-disk path; the backend heals `stream_url` on reconnect.
const filePath = `downloads/${item.id}`;
await downloads.downloadItem(
item.id,
userId,
filePath,
undefined,
undefined,
item.name,
item.artists?.join(", ") ?? undefined,
item.albumName ?? undefined,
);
}
+73
View File
@@ -568,6 +568,79 @@ describe("downloads store", () => {
expect(state.downloads[123].status).toBe("completed");
});
/// A transcode's total is a prediction. The bar must carry that so it can
/// say "~", and the row's persisted size on completion must be the bytes
/// the worker counted — never the prediction.
/// TRACES: UR-071 | DR-290 | UT-254
it("carries the estimate flag through progress and persists the real size on completion", async () => {
const { downloads, initDownloadEvents } = await import("./downloads");
mockInvoke.mockResolvedValueOnce({
downloads: [
{
id: 7,
itemId: "film",
userId: "user-1",
filePath: "videos/movies/film.mp4",
fileSize: 3_000_000_000, // predicted at resolve time
status: "downloading",
progress: 0,
bytesDownloaded: 0,
queuedAt: "2024-01-01T00:00:00Z",
retryCount: 0,
priority: 0,
mediaType: "video",
downloadSource: "user",
},
],
stats: {
total: 1,
activeCount: 1,
queuedCount: 0,
completedCount: 0,
failedCount: 0,
pausedCount: 0,
},
});
await downloads.refresh("user-1");
await initDownloadEvents();
eventHandler!({
payload: {
type: "progress",
downloadId: 7,
itemId: "film",
bytesDownloaded: 1_500_000_000,
totalBytes: 3_000_000_000,
progress: 0.5,
estimated: true,
},
});
let row = get(downloads).downloads[7];
expect(row.fileSizeEstimated).toBe(true);
expect(row.progress).toBe(0.5);
mockInvoke.mockClear();
mockInvoke.mockResolvedValueOnce(undefined);
eventHandler!({
payload: {
type: "completed",
downloadId: 7,
itemId: "film",
filePath: "videos/movies/film.mp4",
bytesDownloaded: 3_120_000_000,
},
});
await new Promise((resolve) => setTimeout(resolve, 50));
row = get(downloads).downloads[7];
expect(row.status).toBe("completed");
expect(row.fileSizeEstimated).toBe(false);
expect(row.fileSize).toBe(3_120_000_000);
const persisted = mockInvoke.mock.calls.find((c) => c[0] === "mark_download_completed");
expect(persisted?.[1]).toMatchObject({ bytesDownloaded: 3_120_000_000 });
});
it("should handle failed event and refresh", async () => {
const { downloads, initDownloadEvents } = await import("./downloads");
+15 -1
View File
@@ -17,6 +17,8 @@ export interface DownloadInfo {
userId: string;
filePath: string;
fileSize?: number;
/** `fileSize` is the backend's prediction (a transcode states no length), not the server's word. */
fileSizeEstimated?: boolean;
mimeType?: string;
status: "pending" | "downloading" | "completed" | "failed" | "paused";
progress: number;
@@ -58,6 +60,8 @@ export interface DownloadEvent {
bytesDownloaded?: number;
totalBytes?: number;
progress?: number;
/** On 'progress': `totalBytes` is an estimate, so `progress` stays below 1 until 'completed'. */
estimated?: boolean;
filePath?: string;
error?: string;
}
@@ -598,17 +602,24 @@ function handleDownloadEvent(payload: DownloadEvent): void {
progress: payload.progress,
bytesDownloaded: payload.bytesDownloaded || download.bytesDownloaded,
fileSize: payload.totalBytes || download.fileSize,
fileSizeEstimated: payload.estimated ?? download.fileSizeEstimated ?? false,
});
}
break;
case "completed":
if (download) {
// The bytes actually written, as the worker counted them. A predicted
// fileSize must never be persisted as the real one (DR-290).
const finalBytes =
payload.bytesDownloaded ||
(download.fileSizeEstimated ? 0 : download.fileSize) ||
download.bytesDownloaded;
// Persist to database
commands
.markDownloadCompleted(
payload.downloadId,
payload.totalBytes || download.fileSize || download.bytesDownloaded,
finalBytes,
payload.filePath || download.filePath,
)
.catch((err) => log.error("Failed to persist download completion:", err));
@@ -616,6 +627,9 @@ function handleDownloadEvent(payload: DownloadEvent): void {
updateDownloadInStore(payload.downloadId, {
status: "completed",
progress: 1.0,
bytesDownloaded: finalBytes,
fileSize: finalBytes,
fileSizeEstimated: false,
completedAt: new Date().toISOString(),
filePath: payload.filePath || download.filePath,
});
+17
View File
@@ -116,6 +116,23 @@ function createExperimentalNativeVideoStore() {
*/
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
/**
* Whether video should take the native path, given the user's stored choice
* and whether this platform lets the user choose at all.
*
* On Android the answer is always native: ExoPlayer is the only video renderer
* there, and the webview element decodes none of the AC-3/E-AC-3/DTS/TrueHD
* that ExoPlayer plays through the FFmpeg extension so a stored "off" would
* turn every original-file download into a silent film (DR-293). Rust reports
* whether a fallback exists (`webviewVideoFallback`); only then does the
* stored choice count.
*
* TRACES: UR-003, UR-071 | DR-293 | UT-262
*/
export function nativeVideoWanted(storedChoice: boolean, webviewVideoFallback: boolean): boolean {
return webviewVideoFallback ? storedChoice : true;
}
function createNativeVideoActiveStore() {
const { subscribe, set } = writable<boolean>(false);
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from "vitest";
import { nativeVideoWanted } from "./nativeVideo";
// TRACES: UR-003, UR-071 | DR-293 | UT-262
describe("nativeVideoWanted", () => {
it("ignores a stored 'off' where there is no webview fallback (Android)", () => {
// Someone who once switched native video off on Android must not be
// routed to the webview, which plays original-file downloads silent.
expect(nativeVideoWanted(false, false)).toBe(true);
expect(nativeVideoWanted(true, false)).toBe(true);
});
it("honours the stored choice where a fallback exists (Linux beside mpv)", () => {
expect(nativeVideoWanted(false, true)).toBe(false);
expect(nativeVideoWanted(true, true)).toBe(true);
});
});
+29
View File
@@ -23,6 +23,7 @@ import {
routeOwnsLayout,
showBottomUi,
shellReservesBottomInset,
showOfflineBanner,
} from "./layoutShell";
const authed = (pathname: string) => ({ pathname, isAuthenticated: true });
@@ -236,3 +237,31 @@ describe("profile picker chrome", () => {
expect(shellReservesBottomInset({ pathname, isAuthenticated: true })).toBe(true);
});
});
/**
* The offline banner is shell chrome, and the full-screen player is chrome-free
* like every other immersive route in this module. On the native Android video
* path VideoPlayer makes itself transparent so the ExoPlayer SurfaceView behind
* the WebView shows through (DR-185), which means any shell element that still
* paints the amber banner appears as a stripe across the top of the film.
*
* TRACES: UR-003 | DR-291 | UT-255
*/
describe("showOfflineBanner", () => {
it("shows while offline on ordinary routes", () => {
expect(showOfflineBanner({ ...authed("/"), isConnected: false })).toBe(true);
expect(showOfflineBanner({ ...authed("/library/movies"), isConnected: false })).toBe(true);
expect(showOfflineBanner({ ...authed("/downloads"), isConnected: false })).toBe(true);
});
it("stays off the full-screen player, where it would paint over the video", () => {
expect(showOfflineBanner({ ...authed("/player/abc123"), isConnected: false })).toBe(false);
});
it("stays off while connected, and while signed out", () => {
expect(showOfflineBanner({ ...authed("/"), isConnected: true })).toBe(false);
expect(showOfflineBanner({ pathname: "/", isAuthenticated: false, isConnected: false })).toBe(
false,
);
});
});
+23
View File
@@ -129,3 +129,26 @@ export function showBottomUi(input: BottomUiVisibilityInput): boolean {
export function shellReservesBottomInset(input: BottomUiVisibilityInput): boolean {
return !showBottomUi(input);
}
/**
* Whether the shell renders the offline banner ("You're offline…").
*
* The banner is shell chrome, and it is the last piece of it that still
* rendered over the full-screen player every other rule in this module
* already treats `/player/*` as immersive. On the native Android video path
* that is not merely untidy: VideoPlayer makes itself transparent so the
* ExoPlayer SurfaceView behind the WebView can be seen (DR-185), so any shell
* element that still paints shows through the film. Offline is also exactly
* when a downloaded video plays, so the banner was most likely to be there
* precisely when it was most in the way and it says nothing the viewer can
* act on while watching: local playback needs no server.
*
* TRACES: UR-003, UR-043 | DR-291
*/
export function showOfflineBanner({
pathname,
isAuthenticated,
isConnected,
}: BottomUiVisibilityInput & { isConnected: boolean }): boolean {
return isAuthenticated && !isConnected && !pathname.startsWith("/player/");
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Tests for the "server only" card rule shared by the grid and the list view.
*
* TRACES: UR-052 | DR-292 | UT-257
*/
import { describe, it, expect } from "vitest";
import { isServerOnly, type ServerOnlyInput } from "./serverOnly";
const offlineReveal: ServerOnlyInput = {
isMediaItem: true,
isConnected: false,
revealServerCatalog: true,
isDownloaded: false,
isActivelyDownloading: false,
hasDeviceContent: false,
};
describe("isServerOnly", () => {
it("is true only offline, with the reveal on, for an item with nothing on the device", () => {
expect(isServerOnly(offlineReveal)).toBe(true);
expect(isServerOnly({ ...offlineReveal, isConnected: true })).toBe(false);
expect(isServerOnly({ ...offlineReveal, revealServerCatalog: false })).toBe(false);
});
it("never greys a library tile: there is nothing to queue", () => {
expect(isServerOnly({ ...offlineReveal, isMediaItem: false })).toBe(false);
});
it("does not grey the item's own completed or in-flight download", () => {
expect(isServerOnly({ ...offlineReveal, isDownloaded: true })).toBe(false);
expect(isServerOnly({ ...offlineReveal, isActivelyDownloading: true })).toBe(false);
});
it("does not grey a container whose children are on the device", () => {
// The regression: an album has no download row of its own — its *tracks*
// do — so a fully downloaded album greyed itself out and offered to queue
// what was already there.
expect(isServerOnly({ ...offlineReveal, hasDeviceContent: true })).toBe(false);
});
it("still greys a container with nothing downloaded under it", () => {
expect(isServerOnly({ ...offlineReveal, hasDeviceContent: false })).toBe(true);
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Is a card "server only" revealed by the offline "Show all server media"
* toggle, but with nothing on the device behind it?
*
* Such cards render greyed out, are inert to tap (there is nothing to play),
* and offer to queue a download for the next reconnect instead.
*
* This lives in its own module because two surfaces answer the question the
* grid (`MediaCard`) and the list (`LibraryListView`) and they disagreed:
* the list had no notion of server-only at all, so switching a library to list
* view offline turned every non-downloaded item back into a normal, tappable
* row that plays nothing.
*
* `hasDeviceContent` is the fix for the second half of that defect. An item's
* *own* download row only ever exists for a playable leaf (Audio, Movie,
* Episode); an album or a season never has one, so a fully downloaded album
* greyed itself out and offered to queue what was already on the device. The
* caller passes the backend's answer `get_download_disk_usage().sizes`
* carries container subtotals as well as leaf sizes rather than the frontend
* deciding which item types are containers, which is taxonomy that belongs in
* Rust.
*
* TRACES: UR-052 | DR-292 | UT-257
*/
export interface ServerOnlyInput {
/** False for a `Library` tile — a library is never queued or greyed. */
isMediaItem: boolean;
/** Server reachability (`$isConnected`). */
isConnected: boolean;
/** The offline banner's "Show all server media" toggle. */
revealServerCatalog: boolean;
/** This item's own download row is `completed`. */
isDownloaded: boolean;
/** This item's own download row is actively transferring. */
isActivelyDownloading: boolean;
/** The device holds bytes at or under this item (leaf file or container). */
hasDeviceContent: boolean;
}
export function isServerOnly({
isMediaItem,
isConnected,
revealServerCatalog,
isDownloaded,
isActivelyDownloading,
hasDeviceContent,
}: ServerOnlyInput): boolean {
if (!isMediaItem) return false;
if (isConnected || !revealServerCatalog) return false;
return !isDownloaded && !isActivelyDownloading && !hasDeviceContent;
}
+29 -2
View File
@@ -19,6 +19,8 @@
showServerCatalog,
lastCatalogSync,
} from "$lib/services/offlineCatalog";
import { downloadedCatalog } from "$lib/services/downloadedCatalog";
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
import { playbackMode } from "$lib/stores/playbackMode";
import { sessions } from "$lib/stores/sessions";
import ReauthModal from "$lib/components/auth/ReauthModal.svelte";
@@ -39,6 +41,7 @@
showGlobalHeader as computeShowGlobalHeader,
routeOwnsLayout as computeRouteOwnsLayout,
shellReservesBottomInset,
showOfflineBanner as computeShowOfflineBanner,
} from "$lib/utils/layoutShell";
import { registerNavigationTracking } from "$lib/utils/navigation";
import { useScrollRestore } from "$lib/utils/scrollContainer";
@@ -108,6 +111,22 @@
shellReservesBottomInset({ pathname, isAuthenticated: $isAuthenticated }),
);
// The device-content map decides which revealed cards are greyed, so it has
// to be current whenever the offline gate settles — going offline, or
// flipping "Show all server media" — not just at startup. (DR-292)
useOfflineFilterReload(() => downloadedCatalog.refresh());
// The offline banner is shell chrome like the rest, so it stays off the
// full-screen player — where on the native video path it would paint a
// stripe straight through the film (DR-291).
const offlineBannerVisible = $derived(
computeShowOfflineBanner({
pathname,
isAuthenticated: $isAuthenticated,
isConnected: $isConnected,
}),
);
onMount(async () => {
// Detect platform first (synchronously, before any await) so the global
// mini player's Android visibility gate is correct from the first render.
@@ -164,6 +183,14 @@
const userId = get(auth).user?.id;
if (userId) {
downloads.refresh(userId).catch((err) => log.error("Initial downloads refresh failed:", err));
// Which containers hold downloaded children is a backend question
// (`get_download_disk_usage`), and the offline browse needs the answer to
// avoid greying out a fully downloaded album — its *tracks* carry the
// download rows, never the album. Primed here because the map used to be
// loaded only by the Downloads page (DR-292).
downloadedCatalog
.refresh()
.catch((err) => log.error("Initial downloaded-catalog refresh failed:", err));
}
// Start sync service for offline mutation queue
@@ -283,8 +310,8 @@
style:padding-bottom={shellPadsBottom ? "var(--safe-bottom)" : undefined}
>
{#if isInitialized}
<!-- Offline indicator banner -->
{#if $isAuthenticated && !$isConnected}
<!-- Offline indicator banner (never over the player — DR-291) -->
{#if offlineBannerVisible}
<div
class="bg-amber-600/90 text-white px-4 py-2 text-sm flex items-center justify-center gap-2 shrink-0"
>
+10 -8
View File
@@ -131,10 +131,12 @@
{ label: "Unlimited", bytes: 0 },
];
// Native-video opt-in (Android). `supportsNativeVideo` comes from Rust, which
// owns the "does this platform have a native video surface" decision; the
// toggle is hidden entirely where it cannot apply.
let supportsNativeVideo = $state(false);
// Native-video switch. Shown only where Rust reports a webview fallback —
// beside mpv native video on Linux. Never on Android: ExoPlayer is the only
// video renderer there, and the webview would play original-file downloads
// silent (DR-293). Rust owns the decision; the toggle is hidden where it
// cannot apply.
let offerNativeVideoSwitch = $state(false);
let nativeVideoEnabled = $state(false);
const unsubscribeNativeVideo = experimentalNativeVideo.subscribe((v) => {
@@ -156,7 +158,7 @@
onMount(async () => {
await loadSettings();
askOnStart = await commands.profilesGetAskOnStart();
supportsNativeVideo = (await getPlaybackCapabilities()).supportsNativeVideo;
offerNativeVideoSwitch = (await getPlaybackCapabilities()).webviewVideoFallback;
// Which update story this platform gets. Android cannot install its own
// APK, so it is offered the releases page instead of an install button.
@@ -952,9 +954,9 @@
</p>
</div>
<!-- Native video (experimental). Only rendered where the platform's Rust
backend actually has a native video surface (Android). -->
{#if supportsNativeVideo}
<!-- Native video. Only rendered where Rust reports a webview fallback
(Linux beside mpv native video); never on Android (DR-293). -->
{#if offerNativeVideoSwitch}
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
<div class="flex items-center justify-between">
<div class="pr-4">