Compare commits

..
5 Commits
Author SHA1 Message Date
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
Build & Release / Create Release (push) Blocked by required conditions
Traceability Validation / Check Requirement Traces (push) Successful in 13s
Build & Release / Run Tests (push) Successful in 16m28s
Build & Release / Build Linux (push) Waiting to run
Build & Release / Build Windows (push) Waiting to run
Build & Release / Build Android (push) Waiting to run
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
dtourolle 24d85f3738 chore(release): v0.12.1
🏗️ 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 53s
📱 Test APK / Build test APK (push) Successful in 33m27s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m15s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 16m12s
Build & Release / Build Linux (push) Failing after 0s
Build & Release / Build Windows (push) Failing after 0s
Build & Release / Build Android (push) Failing after 0s
Build & Release / Create Release (push) Skipped
2026-09-20 20:56:05 +02:00
dtourolle 1093c5bad8 fix(deps): update rustls to 0.23.45 for RUSTSEC-2026-0285
📱 Test APK / Build test APK (push) Canceled after 0s
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m56s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 1m7s
Traceability Validation / Check Requirement Traces (push) Successful in 31s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m47s
cargo-deny in the Supply Chain job started failing on a new advisory
against the locked rustls 0.23.35 (TLS 1.3 handshake messages accepted
across encryption level boundaries). Upgrade to the patched release.
2026-09-20 20:53:45 +02:00
23 changed files with 7111 additions and 4157 deletions
+51
View File
@@ -9,6 +9,57 @@ 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.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
published vulnerability, and this build carries the fixed release of it. Nothing
in JellyTau itself changed.
### 🔒 Security
- **Updated the TLS library (rustls) to 0.23.45** for
[RUSTSEC-2026-0285](https://rustsec.org/advisories/RUSTSEC-2026-0285). The
version in v0.12.0 accepted TLS 1.3 handshake messages sent at the wrong
encryption level — the same fault as Go's CVE-2025-61730. The handshake stays
authenticated, so someone on the network could not alter or complete a
connection with it; the practical effect was that a server could send in
plaintext what should have been encrypted without the app refusing. Every
JellyTau build from the first release used an affected version. Found by the
dependency-advisory gate in CI, which is what it is there for.
## v0.12.0
JellyTau works against Jellyfin 12. Jellyfin 12.0 shipped on 2026-09-08 and
+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
+7 -1
View File
@@ -490,6 +490,8 @@ 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 |
---
@@ -568,7 +570,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 |
| UR-072 | - | DR-156 |
| UR-073 | - | DR-158 |
| UR-074 | - | DR-162, DR-177, DR-181 |
@@ -836,6 +838,10 @@ 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 |
### Integration Tests
| Test ID | Test Description | Traces To | Status |
+6178 -4042
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.0",
"version": "0.12.2",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT",
+3 -3
View File
@@ -2275,7 +2275,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.12.0"
version = "0.12.2"
dependencies = [
"aes-gcm",
"argon2",
@@ -4000,9 +4000,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.35"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"ring",
+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.0"
version = "0.12.2"
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]
+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"),
+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()
);
}
}
+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
}
+6 -22
View File
@@ -2520,27 +2520,11 @@ 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());
@@ -2566,7 +2550,7 @@ impl MediaRepository for OnlineRepository {
// rather than applied to every `original` download.
//
// TRACES: UR-071, UR-004 | DR-171 | UT-166
_ => match source_audio_codec {
None => match source_audio_codec {
Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
params.push("videoCodec=h264".to_string());
params.push("allowVideoStreamCopy=true".to_string());
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "JellyTau",
"version": "0.12.0",
"version": "0.12.2",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
@@ -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)}`,
};
}
@@ -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 {
+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,
});