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>
This commit is contained in:
@@ -491,6 +491,7 @@ Internal architecture, components, and application logic.
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -569,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, DR-289 |
|
||||
| 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 |
|
||||
@@ -838,6 +839,9 @@ Internal architecture, components, and application logic.
|
||||
| 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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user