Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff21c4cd44 | ||
|
|
b3228cb4f4 | ||
|
|
e271874b1d |
@@ -9,6 +9,39 @@ 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
|
For how long each fixed defect had been shipping before it was found, see
|
||||||
[docs/defect-windows.md](docs/defect-windows.md).
|
[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
|
## v0.12.1
|
||||||
|
|
||||||
One change: the TLS library every connection to the server goes through has a
|
One change: the TLS library every connection to the server goes through has a
|
||||||
|
|||||||
+10
-2
@@ -17,8 +17,8 @@ row can be re-checked or disputed:
|
|||||||
|
|
||||||
## Present since the first release
|
## Present since the first release
|
||||||
|
|
||||||
Sixteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
|
Eighteen 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.
|
shipped for between two weeks and three months before anyone hit them.
|
||||||
That is the dominant pattern here: not regressions, but original assumptions that
|
That is the dominant pattern here: not regressions, but original assumptions that
|
||||||
went unexercised until a later feature leaned on them.
|
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 |
|
| 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 |
|
| 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 |
|
| `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
|
### Why they took so long to surface
|
||||||
|
|
||||||
|
|||||||
@@ -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-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-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-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-068 | - | DR-119 |
|
||||||
| UR-069 | - | DR-113, DR-114, DR-120 |
|
| UR-069 | - | DR-113, DR-114, DR-120 |
|
||||||
| UR-070 | - | DR-121, DR-122 |
|
| 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-072 | - | DR-156 |
|
||||||
| UR-073 | - | DR-158 |
|
| UR-073 | - | DR-158 |
|
||||||
| UR-074 | - | DR-162, DR-177, DR-181 |
|
| 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-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-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-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
|
### Integration Tests
|
||||||
|
|
||||||
| Test ID | Test Description | Traces To | Status |
|
| Test ID | Test Description | Traces To | Status |
|
||||||
|
|||||||
+6178
-4042
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
Generated
+1
-1
@@ -2275,7 +2275,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.12.1"
|
version = "0.12.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ name = "jellytau"
|
|||||||
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
||||||
# which `tauri dev` issues — ambiguous.
|
# which `tauri dev` issues — ambiguous.
|
||||||
default-run = "jellytau"
|
default-run = "jellytau"
|
||||||
version = "0.12.1"
|
version = "0.12.2"
|
||||||
description = "A cross-platform Jellyfin client"
|
description = "A cross-platform Jellyfin client"
|
||||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -150,6 +150,9 @@ ndk-context = "0.1"
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.24.0"
|
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"
|
wiremock = "0.6.5"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@@ -517,6 +517,34 @@ pub(crate) async fn requeue_mistyped_video_downloads(
|
|||||||
Ok(n)
|
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
|
/// Core of [`resume_queued_downloads`], factored out for testing: select every
|
||||||
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
|
/// `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.
|
/// `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>
|
) -> Result<ResumeQueuedResult, String>
|
||||||
where
|
where
|
||||||
F: Fn(String, String, String) -> Fut,
|
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()) {
|
if only_ids.is_some_and(|ids| ids.is_empty()) {
|
||||||
return Ok(ResumeQueuedResult {
|
return Ok(ResumeQueuedResult {
|
||||||
@@ -600,8 +628,8 @@ where
|
|||||||
let mut failed = 0usize;
|
let mut failed = 0usize;
|
||||||
|
|
||||||
for (download_id, item_id, media_type, quality) in rows {
|
for (download_id, item_id, media_type, quality) in rows {
|
||||||
let stream_url = match resolve(item_id.clone(), media_type, quality).await {
|
let target = match resolve(item_id.clone(), media_type, quality).await {
|
||||||
Some(url) => url,
|
Some(target) => target,
|
||||||
None => {
|
None => {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -609,13 +637,21 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Heal the row so the pump can start it. Guard on stream_url IS NULL so a
|
// 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(
|
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",
|
WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
|
||||||
vec![
|
vec![
|
||||||
QueryParam::String(stream_url),
|
QueryParam::String(target.url),
|
||||||
QueryParam::String(target_dir.to_string()),
|
QueryParam::String(target_dir.to_string()),
|
||||||
|
expected,
|
||||||
QueryParam::Int64(download_id),
|
QueryParam::Int64(download_id),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -705,17 +741,18 @@ pub async fn resume_queued_downloads(
|
|||||||
async move {
|
async move {
|
||||||
if media_type == "video" {
|
if media_type == "video" {
|
||||||
Some(
|
Some(
|
||||||
crate::repository::resolve_video_download_url(
|
crate::repository::resolve_video_download(
|
||||||
repo.as_ref(),
|
repo.as_ref(),
|
||||||
&item_id,
|
&item_id,
|
||||||
&quality,
|
&quality,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await,
|
.await
|
||||||
|
.into(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
match repo.get_audio_stream_url(&item_id).await {
|
match repo.get_audio_stream_url(&item_id).await {
|
||||||
Ok(url) => Some(url),
|
Ok(url) => Some(url.into()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
"[Catalog] Failed to resolve audio URL for {}: {:?}",
|
"[Catalog] Failed to resolve audio URL for {}: {:?}",
|
||||||
@@ -807,6 +844,7 @@ mod tests {
|
|||||||
target_dir TEXT,
|
target_dir TEXT,
|
||||||
media_type TEXT,
|
media_type TEXT,
|
||||||
quality_preset TEXT,
|
quality_preset TEXT,
|
||||||
|
file_size INTEGER,
|
||||||
progress REAL DEFAULT 0,
|
progress REAL DEFAULT 0,
|
||||||
bytes_downloaded INTEGER DEFAULT 0,
|
bytes_downloaded INTEGER DEFAULT 0,
|
||||||
started_at TEXT,
|
started_at TEXT,
|
||||||
@@ -890,7 +928,7 @@ mod tests {
|
|||||||
&db,
|
&db,
|
||||||
"/data/downloads",
|
"/data/downloads",
|
||||||
None,
|
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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -932,7 +970,7 @@ mod tests {
|
|||||||
&db,
|
&db,
|
||||||
"/data",
|
"/data",
|
||||||
Some(&[mine]),
|
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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -962,7 +1000,7 @@ mod tests {
|
|||||||
|
|
||||||
let out =
|
let out =
|
||||||
resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
|
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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1015,7 +1053,7 @@ mod tests {
|
|||||||
let seen = Arc::clone(&seen_c);
|
let seen = Arc::clone(&seen_c);
|
||||||
async move {
|
async move {
|
||||||
seen.lock_safe().push((item_id.clone(), media_type));
|
seen.lock_safe().push((item_id.clone(), media_type));
|
||||||
Some(format!("http://resolved/{item_id}"))
|
Some(format!("http://resolved/{item_id}").into())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -1048,7 +1086,7 @@ mod tests {
|
|||||||
let seen = Arc::clone(&seen_c);
|
let seen = Arc::clone(&seen_c);
|
||||||
async move {
|
async move {
|
||||||
*seen.lock_safe() = media_type;
|
*seen.lock_safe() = media_type;
|
||||||
Some("http://x".to_string())
|
Some("http://x".to_string().into())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -1072,7 +1110,7 @@ mod tests {
|
|||||||
let seen = Arc::clone(&seen_c);
|
let seen = Arc::clone(&seen_c);
|
||||||
async move {
|
async move {
|
||||||
*seen.lock_safe() = media_type;
|
*seen.lock_safe() = media_type;
|
||||||
Some("http://x".to_string())
|
Some("http://x".to_string().into())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -1123,6 +1161,51 @@ mod tests {
|
|||||||
assert_eq!(status, "completed", "a correct video download is untouched");
|
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]
|
#[tokio::test]
|
||||||
async fn video_rows_use_media_type_in_resolver() {
|
async fn video_rows_use_media_type_in_resolver() {
|
||||||
let db = test_db();
|
let db = test_db();
|
||||||
@@ -1134,7 +1217,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
|item_id, media_type, _q| async move {
|
|item_id, media_type, _q| async move {
|
||||||
assert_eq!(media_type, "video");
|
assert_eq!(media_type, "video");
|
||||||
Some(format!("http://transcode/{item_id}"))
|
Some(format!("http://transcode/{item_id}").into())
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -759,7 +759,7 @@ pub async fn download_album(
|
|||||||
async move {
|
async move {
|
||||||
use crate::repository::MediaRepository;
|
use crate::repository::MediaRepository;
|
||||||
match repo.get_audio_stream_url(&item_id).await {
|
match repo.get_audio_stream_url(&item_id).await {
|
||||||
Ok(url) => Some(url),
|
Ok(url) => Some(url.into()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
"[download_album] Failed to resolve stream URL for {}: {:?}",
|
"[download_album] Failed to resolve stream URL for {}: {:?}",
|
||||||
@@ -1604,6 +1604,9 @@ pub async fn start_download(
|
|||||||
item_id,
|
item_id,
|
||||||
stream_url,
|
stream_url,
|
||||||
target_path,
|
target_path,
|
||||||
|
file_size_from_server
|
||||||
|
.or(file_size)
|
||||||
|
.and_then(|n| u64::try_from(n).ok()),
|
||||||
active_downloads,
|
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
|
// 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
|
// track this device cannot decode is re-encoded on the way down rather
|
||||||
// than saved as a silent file (DR-167).
|
// than saved as a silent file (DR-167).
|
||||||
let stream_url =
|
let resolved =
|
||||||
crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
|
crate::repository::resolve_video_download(repo.as_ref(), &item_id, &quality, None)
|
||||||
.await;
|
.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(
|
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![
|
vec![
|
||||||
QueryParam::String(stream_url),
|
QueryParam::String(resolved.url),
|
||||||
QueryParam::String(target_dir.clone()),
|
QueryParam::String(target_dir.clone()),
|
||||||
|
expected_bytes_param(resolved.expected_bytes),
|
||||||
QueryParam::Int64(download_id),
|
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
|
// Find the next pending, startable download (has a stream URL). Exclude
|
||||||
// anything already registered as active to avoid double-starting.
|
// anything already registered as active to avoid double-starting.
|
||||||
let next_query = Query::with_params(
|
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
|
FROM downloads
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
AND stream_url IS NOT NULL
|
AND stream_url IS NOT NULL
|
||||||
@@ -1828,7 +1836,7 @@ pub(crate) async fn pump_download_queue(
|
|||||||
vec![],
|
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| {
|
.query_many(next_query, |row| {
|
||||||
Ok((
|
Ok((
|
||||||
row.get(0)?,
|
row.get(0)?,
|
||||||
@@ -1836,6 +1844,7 @@ pub(crate) async fn pump_download_queue(
|
|||||||
row.get(2)?,
|
row.get(2)?,
|
||||||
row.get(3)?,
|
row.get(3)?,
|
||||||
row.get(4)?,
|
row.get(4)?,
|
||||||
|
row.get(5)?,
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -1848,14 +1857,14 @@ pub(crate) async fn pump_download_queue(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Pick the first candidate not already active.
|
// Pick the first candidate not already active.
|
||||||
let next = candidates.into_iter().find(|(id, _, _, _, _)| {
|
let next = candidates.into_iter().find(|(id, _, _, _, _, _)| {
|
||||||
active_downloads
|
active_downloads
|
||||||
.lock()
|
.lock()
|
||||||
.map(|active| !active.contains(id))
|
.map(|active| !active.contains(id))
|
||||||
.unwrap_or(false)
|
.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,
|
Some(n) => n,
|
||||||
None => return, // Nothing pending to start
|
None => return, // Nothing pending to start
|
||||||
};
|
};
|
||||||
@@ -1943,11 +1952,20 @@ pub(crate) async fn pump_download_queue(
|
|||||||
item_id,
|
item_id,
|
||||||
stream_url,
|
stream_url,
|
||||||
target_path,
|
target_path,
|
||||||
|
file_size.and_then(|n| u64::try_from(n).ok()),
|
||||||
active_downloads.clone(),
|
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
|
/// 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
|
/// unregisters the slot, emits the terminal event, and pumps the queue so the
|
||||||
/// next pending download starts automatically.
|
/// next pending download starts automatically.
|
||||||
@@ -1957,6 +1975,7 @@ fn spawn_download_worker(
|
|||||||
item_id: String,
|
item_id: String,
|
||||||
stream_url: String,
|
stream_url: String,
|
||||||
target_path: std::path::PathBuf,
|
target_path: std::path::PathBuf,
|
||||||
|
expected_bytes: Option<u64>,
|
||||||
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
|
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
|
||||||
) {
|
) {
|
||||||
use crate::download::events::DownloadEvent;
|
use crate::download::events::DownloadEvent;
|
||||||
@@ -1975,18 +1994,19 @@ fn spawn_download_worker(
|
|||||||
// Progress callback that emits events to the frontend
|
// Progress callback that emits events to the frontend
|
||||||
let progress_app = app.clone();
|
let progress_app = app.clone();
|
||||||
let progress_item_id = item_id.clone();
|
let progress_item_id = item_id.clone();
|
||||||
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
|
let on_progress = move |bytes_downloaded: u64, content_length: Option<u64>| {
|
||||||
let progress = total_bytes
|
// The server's length when it gave one; the prediction made at
|
||||||
.filter(|&t| t > 0)
|
// resolve time when it did not (a transcode). DR-290
|
||||||
.map(|t| bytes_downloaded as f64 / t as f64)
|
let total = crate::download::estimate::progress_total(content_length, expected_bytes);
|
||||||
.unwrap_or(0.0);
|
let progress = crate::download::estimate::progress_fraction(bytes_downloaded, total);
|
||||||
|
|
||||||
let event = DownloadEvent::Progress {
|
let event = DownloadEvent::Progress {
|
||||||
download_id,
|
download_id,
|
||||||
item_id: progress_item_id.clone(),
|
item_id: progress_item_id.clone(),
|
||||||
bytes_downloaded: bytes_downloaded as i64,
|
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,
|
progress,
|
||||||
|
estimated: total.is_some_and(|t| t.estimated),
|
||||||
};
|
};
|
||||||
let _ = progress_app.emit("download-event", event);
|
let _ = progress_app.emit("download-event", event);
|
||||||
};
|
};
|
||||||
@@ -2060,6 +2080,7 @@ fn spawn_download_worker(
|
|||||||
download_id,
|
download_id,
|
||||||
item_id,
|
item_id,
|
||||||
file_path,
|
file_path,
|
||||||
|
bytes_downloaded: res.bytes_downloaded as i64,
|
||||||
};
|
};
|
||||||
match app.emit("download-event", completed_event) {
|
match app.emit("download-event", completed_event) {
|
||||||
Ok(_) => debug!(" Completed event emitted successfully"),
|
Ok(_) => debug!(" Completed event emitted successfully"),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,10 @@ pub enum DownloadEvent {
|
|||||||
bytes_downloaded: i64,
|
bytes_downloaded: i64,
|
||||||
total_bytes: Option<i64>,
|
total_bytes: Option<i64>,
|
||||||
progress: f64, // 0.0 to 1.0
|
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
|
/// Download completed successfully
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -27,6 +31,10 @@ pub enum DownloadEvent {
|
|||||||
download_id: i64,
|
download_id: i64,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
file_path: 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
|
/// Download failed with error
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -60,6 +68,7 @@ mod tests {
|
|||||||
bytes_downloaded: 1024,
|
bytes_downloaded: 1024,
|
||||||
total_bytes: Some(2048),
|
total_bytes: Some(2048),
|
||||||
progress: 0.5,
|
progress: 0.5,
|
||||||
|
estimated: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
@@ -86,6 +95,7 @@ mod tests {
|
|||||||
download_id: 42,
|
download_id: 42,
|
||||||
item_id: "song456".to_string(),
|
item_id: "song456".to_string(),
|
||||||
file_path: "/path/to/file.mp3".to_string(),
|
file_path: "/path/to/file.mp3".to_string(),
|
||||||
|
bytes_downloaded: 4096,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
//! - Resume support via HTTP Range requests
|
//! - Resume support via HTTP Range requests
|
||||||
|
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
|
pub mod estimate;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
|
pub mod presets;
|
||||||
pub mod stop;
|
pub mod stop;
|
||||||
pub mod worker;
|
pub mod worker;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,11 +18,40 @@ pub struct DownloadWorker {
|
|||||||
max_retries: u32,
|
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 {
|
impl DownloadWorker {
|
||||||
pub fn new() -> Self {
|
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()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(300)) // 5 minute timeout
|
.connect_timeout(CONNECT_TIMEOUT)
|
||||||
.https_only(true)
|
.read_timeout(stall)
|
||||||
|
.https_only(https_only)
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to create HTTP client");
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
@@ -436,3 +465,117 @@ mod tests {
|
|||||||
assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
/// [`resolve_video_download_url`] rather than calling it directly.
|
/// [`resolve_video_download_url`] rather than calling it directly.
|
||||||
///
|
///
|
||||||
/// `source_audio_codec` is the codec of the audio track the server would
|
/// `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
|
/// `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
|
/// 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*.
|
/// 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>;
|
) -> Result<(), RepoError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The audio codec the server would serve for `item_id` — the default track, or
|
/// A video download, resolved: the URL to fetch and, where the item told us
|
||||||
/// the first when none is marked, matching the track Jellyfin picks.
|
/// 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
|
/// Every video download goes through here rather than calling the builder
|
||||||
/// caller must read that as "unknown", never as "fine": it is the input to a
|
/// directly: the builder is pure and cannot look the codec up, and a caller that
|
||||||
/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
|
/// forgets to is exactly how the silent downloads shipped.
|
||||||
/// exactly as it was.
|
|
||||||
///
|
///
|
||||||
/// TRACES: UR-071 | DR-171 | UT-166
|
/// TRACES: UR-071 | DR-171, DR-290
|
||||||
pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
|
pub async fn resolve_video_download(
|
||||||
let item = repo.get_item(item_id).await.ok()?;
|
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
|
let audio: Vec<(Option<&str>, bool)> = item
|
||||||
.media_streams
|
.as_ref()
|
||||||
.as_deref()
|
.and_then(|i| i.media_streams.as_deref())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|s| s.stream_type == "Audio")
|
.filter(|s| s.stream_type == "Audio")
|
||||||
.map(|s| (s.codec.as_deref(), s.is_default))
|
.map(|s| (s.codec.as_deref(), s.is_default))
|
||||||
.collect();
|
.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
|
/// [`resolve_video_download`] for callers that only need the URL.
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// TRACES: UR-071 | DR-171
|
/// TRACES: UR-071 | DR-171
|
||||||
pub async fn resolve_video_download_url(
|
pub async fn resolve_video_download_url(
|
||||||
@@ -382,6 +417,7 @@ pub async fn resolve_video_download_url(
|
|||||||
quality: &str,
|
quality: &str,
|
||||||
media_source_id: Option<&str>,
|
media_source_id: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let codec = served_audio_codec(repo, item_id).await;
|
resolve_video_download(repo, item_id, quality, media_source_id)
|
||||||
repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
|
.await
|
||||||
|
.url
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2520,27 +2520,11 @@ impl MediaRepository for OnlineRepository {
|
|||||||
// fine in itself, but it also means a mis-typed cap degrades silently.
|
// fine in itself, but it also means a mis-typed cap degrades silently.
|
||||||
// Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
|
// Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
|
||||||
// video copy is gated by `allowVideoStreamCopy`.
|
// video copy is gated by `allowVideoStreamCopy`.
|
||||||
match quality {
|
match crate::download::presets::download_preset(quality) {
|
||||||
"high" => {
|
Some(preset) => {
|
||||||
params.push("videoBitRate=8000000".to_string());
|
params.push(format!("videoBitRate={}", preset.video_bit_rate));
|
||||||
params.push("maxHeight=1080".to_string());
|
params.push(format!("maxHeight={}", preset.max_height));
|
||||||
params.push("audioBitRate=384000".to_string());
|
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());
|
|
||||||
}
|
|
||||||
"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());
|
|
||||||
params.push("videoCodec=h264".to_string());
|
params.push("videoCodec=h264".to_string());
|
||||||
params.push("audioCodec=aac".to_string());
|
params.push("audioCodec=aac".to_string());
|
||||||
params.push("allowVideoStreamCopy=false".to_string());
|
params.push("allowVideoStreamCopy=false".to_string());
|
||||||
@@ -2566,7 +2550,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
// rather than applied to every `original` download.
|
// rather than applied to every `original` download.
|
||||||
//
|
//
|
||||||
// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
// 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) => {
|
Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
|
||||||
params.push("videoCodec=h264".to_string());
|
params.push("videoCodec=h264".to_string());
|
||||||
params.push("allowVideoStreamCopy=true".to_string());
|
params.push("allowVideoStreamCopy=true".to_string());
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "JellyTau",
|
"productName": "JellyTau",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
||||||
|
import { describeProgress, formatBytes } from "./downloadProgress";
|
||||||
import { createLogger } from "$lib/utils/logger";
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
const log = createLogger("DownloadItem");
|
const log = createLogger("DownloadItem");
|
||||||
@@ -10,20 +11,8 @@
|
|||||||
|
|
||||||
let { download }: Props = $props();
|
let { download }: Props = $props();
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
// Exact, estimated, or unknown total — see downloadProgress.ts (DR-290).
|
||||||
if (bytes === 0) return "0 B";
|
const view = $derived(describeProgress(download));
|
||||||
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)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusColor(): string {
|
function getStatusColor(): string {
|
||||||
switch (download.status) {
|
switch (download.status) {
|
||||||
@@ -201,15 +190,24 @@
|
|||||||
|
|
||||||
<!-- Progress Bar (for active/paused downloads) -->
|
<!-- Progress Bar (for active/paused downloads) -->
|
||||||
{#if download.status === "downloading" || download.status === "paused"}
|
{#if download.status === "downloading" || download.status === "paused"}
|
||||||
<div class="w-full bg-gray-700 rounded-full h-2 mb-2">
|
<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
|
<div
|
||||||
class="h-2 rounded-full transition-all duration-300 {getStatusColor()}"
|
class="h-2 rounded-full transition-all duration-300 {getStatusColor()}"
|
||||||
style="width: {download.progress * 100}%"
|
style="width: {view.percent}%"
|
||||||
></div>
|
></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between text-xs text-gray-400">
|
<div class="flex items-center justify-between text-xs text-gray-400">
|
||||||
<span>{Math.round(download.progress * 100)}%</span>
|
<span>{view.percentLabel}</span>
|
||||||
<span>{formatProgress()}</span>
|
<span>{view.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if download.status === "completed"}
|
{:else if download.status === "completed"}
|
||||||
<p class="text-xs text-gray-400">{formatBytes(download.bytesDownloaded)}</p>
|
<p class="text-xs text-gray-400">{formatBytes(download.bytesDownloaded)}</p>
|
||||||
@@ -365,3 +363,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const repo = auth.getRepository();
|
const handle = auth.getRepository().getHandle();
|
||||||
|
|
||||||
log.debug("🎬 Starting video download for item:", itemId, "quality:", quality);
|
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
|
// Get target directory
|
||||||
const targetDir = await commands.storageGetPath();
|
const targetDir = await commands.storageGetPath();
|
||||||
|
|
||||||
@@ -109,9 +105,12 @@
|
|||||||
// Pin the item metadata
|
// Pin the item metadata
|
||||||
await downloads.pinItem(itemId);
|
await downloads.pinItem(itemId);
|
||||||
|
|
||||||
// Actually start the download
|
// Resolve the URL and start it the same way the series/season buttons
|
||||||
await commands.startDownload(downloadId, streamUrl, targetDir);
|
// do: Rust picks the transcode from the row's preset, predicts the size
|
||||||
log.debug(" Download started");
|
// 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) {
|
} catch (error) {
|
||||||
log.error("Failed to start video download:", error);
|
log.error("Failed to start video download:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -568,6 +568,79 @@ describe("downloads store", () => {
|
|||||||
expect(state.downloads[123].status).toBe("completed");
|
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 () => {
|
it("should handle failed event and refresh", async () => {
|
||||||
const { downloads, initDownloadEvents } = await import("./downloads");
|
const { downloads, initDownloadEvents } = await import("./downloads");
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface DownloadInfo {
|
|||||||
userId: string;
|
userId: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
|
/** `fileSize` is the backend's prediction (a transcode states no length), not the server's word. */
|
||||||
|
fileSizeEstimated?: boolean;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
status: "pending" | "downloading" | "completed" | "failed" | "paused";
|
status: "pending" | "downloading" | "completed" | "failed" | "paused";
|
||||||
progress: number;
|
progress: number;
|
||||||
@@ -58,6 +60,8 @@ export interface DownloadEvent {
|
|||||||
bytesDownloaded?: number;
|
bytesDownloaded?: number;
|
||||||
totalBytes?: number;
|
totalBytes?: number;
|
||||||
progress?: number;
|
progress?: number;
|
||||||
|
/** On 'progress': `totalBytes` is an estimate, so `progress` stays below 1 until 'completed'. */
|
||||||
|
estimated?: boolean;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@@ -598,17 +602,24 @@ function handleDownloadEvent(payload: DownloadEvent): void {
|
|||||||
progress: payload.progress,
|
progress: payload.progress,
|
||||||
bytesDownloaded: payload.bytesDownloaded || download.bytesDownloaded,
|
bytesDownloaded: payload.bytesDownloaded || download.bytesDownloaded,
|
||||||
fileSize: payload.totalBytes || download.fileSize,
|
fileSize: payload.totalBytes || download.fileSize,
|
||||||
|
fileSizeEstimated: payload.estimated ?? download.fileSizeEstimated ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "completed":
|
case "completed":
|
||||||
if (download) {
|
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
|
// Persist to database
|
||||||
commands
|
commands
|
||||||
.markDownloadCompleted(
|
.markDownloadCompleted(
|
||||||
payload.downloadId,
|
payload.downloadId,
|
||||||
payload.totalBytes || download.fileSize || download.bytesDownloaded,
|
finalBytes,
|
||||||
payload.filePath || download.filePath,
|
payload.filePath || download.filePath,
|
||||||
)
|
)
|
||||||
.catch((err) => log.error("Failed to persist download completion:", err));
|
.catch((err) => log.error("Failed to persist download completion:", err));
|
||||||
@@ -616,6 +627,9 @@ function handleDownloadEvent(payload: DownloadEvent): void {
|
|||||||
updateDownloadInStore(payload.downloadId, {
|
updateDownloadInStore(payload.downloadId, {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
progress: 1.0,
|
progress: 1.0,
|
||||||
|
bytesDownloaded: finalBytes,
|
||||||
|
fileSize: finalBytes,
|
||||||
|
fileSizeEstimated: false,
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
filePath: payload.filePath || download.filePath,
|
filePath: payload.filePath || download.filePath,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user