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:
2026-09-21 11:18:29 +02:00
co-authored by Claude Opus 5
parent e271874b1d
commit b3228cb4f4
15 changed files with 716 additions and 105 deletions
+181
View File
@@ -0,0 +1,181 @@
//! How big a download is going to be when the server will not say.
//!
//! A direct copy answers with `Content-Length`, and the worker reports exact
//! progress from it. A transcode is produced as it is sent — chunked, with no
//! length — and the worker used to report `progress: 0.0` for its whole
//! duration: an empty bar and "0%" while the byte count climbed for an hour.
//! That is the case every film whose audio must be re-encoded lands in.
//!
//! The backend does know enough to estimate. It fetches the item to decide the
//! audio policy anyway, and that item carries the source's size and runtime;
//! the preset it chose fixes the bitrate. So the estimate is made where the
//! URL is, persisted on the row as its `file_size`, and used only as a
//! fallback: a real `Content-Length` always wins, and an estimated bar never
//! claims completion.
//!
//! TRACES: UR-071 | DR-290
use super::presets::download_preset;
/// Ticks per second in Jellyfin's runtime unit.
const TICKS_PER_SECOND: u64 = 10_000_000;
/// The progress bar never reports more than this from an estimate, so a source
/// that encodes a little larger than predicted shows 99% until the last byte
/// rather than 104% — completion is the worker's to announce.
pub const ESTIMATED_PROGRESS_CEILING: f64 = 0.99;
/// The size a download for `quality` is expected to produce, in bytes.
///
/// - A preset re-encodes both streams at fixed rates, so the size is rate ×
/// runtime. Jellyfin encodes to a target bitrate (`-b:v` with `-maxrate`), so
/// the average lands near the cap rather than well under it.
/// - `original` copies the picture and at most re-encodes the audio, so the
/// output is the source's size give or take the audio track — and when no
/// transcode is needed at all it is exactly the source's size.
///
/// `None` when the inputs needed are missing; the caller then has no total and
/// the bar is indeterminate, which is honest and was the status quo.
///
/// TRACES: UR-071 | DR-290 | UT-252
pub fn expected_download_bytes(
quality: &str,
runtime_ticks: Option<i64>,
source_size: Option<i64>,
) -> Option<u64> {
match download_preset(quality) {
Some(preset) => {
let seconds = u64::try_from(runtime_ticks?).ok()? / TICKS_PER_SECOND;
(seconds > 0).then(|| preset.total_bit_rate() / 8 * seconds)
}
None => source_size
.and_then(|s| u64::try_from(s).ok())
.filter(|&s| s > 0),
}
}
/// What the progress bar measures against.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProgressTotal {
pub bytes: u64,
/// The total is a prediction, not the server's word.
pub estimated: bool,
}
/// The total to report progress against, given what the response said and what
/// was predicted before it was made. The server's `Content-Length` always
/// wins; the estimate fills in only when the server sent none.
///
/// TRACES: UR-071 | DR-290 | UT-252
pub fn progress_total(content_length: Option<u64>, expected: Option<u64>) -> Option<ProgressTotal> {
match (
content_length.filter(|&n| n > 0),
expected.filter(|&n| n > 0),
) {
(Some(bytes), _) => Some(ProgressTotal {
bytes,
estimated: false,
}),
(None, Some(bytes)) => Some(ProgressTotal {
bytes,
estimated: true,
}),
(None, None) => None,
}
}
/// The fraction complete, in `0.0..=1.0`. An estimated total is capped at
/// [`ESTIMATED_PROGRESS_CEILING`] so a prediction that ran low never shows a
/// finished bar on a download still running.
///
/// TRACES: UR-071 | DR-290 | UT-252
pub fn progress_fraction(downloaded: u64, total: Option<ProgressTotal>) -> f64 {
let Some(total) = total else { return 0.0 };
let fraction = downloaded as f64 / total.bytes as f64;
let ceiling = if total.estimated {
ESTIMATED_PROGRESS_CEILING
} else {
1.0
};
fraction.clamp(0.0, ceiling)
}
#[cfg(test)]
mod tests {
use super::*;
const HOUR_TICKS: i64 = 3600 * TICKS_PER_SECOND as i64;
/// A transcode has no `Content-Length`, and this is the case that showed
/// "0%" for its whole duration: with a prediction in hand the bar must move.
///
/// TRACES: UR-071 | DR-290 | UT-252
#[test]
fn test_estimate_fills_in_when_the_server_sent_no_length() {
let total = progress_total(None, Some(4_000));
assert_eq!(
total,
Some(ProgressTotal {
bytes: 4_000,
estimated: true
})
);
let fraction = progress_fraction(1_000, total);
assert!((fraction - 0.25).abs() < 1e-9, "got {fraction}");
}
/// The server's own figure is never second-guessed by a prediction.
#[test]
fn test_content_length_wins_over_the_estimate() {
let total = progress_total(Some(10_000), Some(4_000)).unwrap();
assert_eq!(total.bytes, 10_000);
assert!(!total.estimated);
assert_eq!(progress_fraction(10_000, Some(total)), 1.0);
}
/// A prediction that ran low must not announce completion: that is the
/// worker's to do when the last byte lands.
#[test]
fn test_estimated_progress_never_reaches_one() {
let total = progress_total(None, Some(1_000));
assert_eq!(progress_fraction(1_200, total), ESTIMATED_PROGRESS_CEILING);
assert_eq!(progress_fraction(0, total), 0.0);
}
/// Nothing known → nothing claimed, and a zero length is "nothing known".
#[test]
fn test_no_total_means_no_progress_claim() {
assert_eq!(progress_total(None, None), None);
assert_eq!(progress_total(Some(0), Some(0)), None);
assert_eq!(progress_fraction(500, None), 0.0);
}
/// A preset's size is its combined rate over the runtime — one hour of the
/// medium preset (4 Mb/s + 256 kb/s) is about 1.9 GB.
#[test]
fn test_preset_estimate_is_rate_times_runtime() {
let bytes = expected_download_bytes("medium", Some(HOUR_TICKS), Some(9_999)).unwrap();
assert_eq!(bytes, (4_000_000 + 256_000) / 8 * 3600);
// Without a runtime there is nothing to multiply.
assert_eq!(expected_download_bytes("medium", None, Some(9_999)), None);
assert_eq!(expected_download_bytes("medium", Some(0), None), None);
}
/// `original` copies the picture, so the source's size is the prediction —
/// with or without the audio being re-encoded on the way.
#[test]
fn test_original_estimate_is_the_source_size() {
assert_eq!(
expected_download_bytes("original", Some(HOUR_TICKS), Some(3_000_000_000)),
Some(3_000_000_000)
);
assert_eq!(
expected_download_bytes("original", Some(HOUR_TICKS), None),
None
);
assert_eq!(expected_download_bytes("original", None, Some(0)), None);
// An unknown quality name is treated as original by the URL builder,
// so it is here too.
assert_eq!(expected_download_bytes("wat", None, Some(10)), Some(10));
}
}
+10
View File
@@ -20,6 +20,10 @@ pub enum DownloadEvent {
bytes_downloaded: i64,
total_bytes: Option<i64>,
progress: f64, // 0.0 to 1.0
/// `total_bytes` is a prediction rather than the server's
/// `Content-Length`, so `progress` stops short of 1.0 until the
/// download completes. TRACES: UR-071 | DR-290
estimated: bool,
},
/// Download completed successfully
#[serde(rename_all = "camelCase")]
@@ -27,6 +31,10 @@ pub enum DownloadEvent {
download_id: i64,
item_id: String,
file_path: String,
/// Bytes actually written. The frontend persists completion too, and
/// without this it fell back to the row's `file_size` — which is a
/// prediction for a transcode (DR-290), not the real size.
bytes_downloaded: i64,
},
/// Download failed with error
#[serde(rename_all = "camelCase")]
@@ -60,6 +68,7 @@ mod tests {
bytes_downloaded: 1024,
total_bytes: Some(2048),
progress: 0.5,
estimated: false,
};
let json = serde_json::to_string(&event).unwrap();
@@ -86,6 +95,7 @@ mod tests {
download_id: 42,
item_id: "song456".to_string(),
file_path: "/path/to/file.mp3".to_string(),
bytes_downloaded: 4096,
};
let json = serde_json::to_string(&event).unwrap();
+2
View File
@@ -7,8 +7,10 @@
//! - Resume support via HTTP Range requests
pub mod cache;
pub mod estimate;
pub mod events;
pub mod network;
pub mod presets;
pub mod stop;
pub mod worker;
+66
View File
@@ -0,0 +1,66 @@
//! The quality presets a video download can be asked for.
//!
//! One table, read by both the URL builder (which turns a preset into transcode
//! parameters) and the size estimate (which turns the same numbers into an
//! expected byte count). They were the same literals in two places before,
//! which is how a bar can claim 40% of a file that is nearly done.
/// Transcode caps for one named preset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DownloadPreset {
/// Video target, bits per second.
pub video_bit_rate: u64,
/// Longest edge the picture is scaled down to.
pub max_height: u32,
/// Audio target, bits per second.
pub audio_bit_rate: u64,
}
impl DownloadPreset {
/// Combined stream rate, bits per second.
pub fn total_bit_rate(&self) -> u64 {
self.video_bit_rate + self.audio_bit_rate
}
}
/// The preset a quality name denotes; `None` for `original` and anything
/// unrecognised, both of which mean "do not cap the picture".
///
/// TRACES: UR-071 | DR-123, DR-290
pub fn download_preset(quality: &str) -> Option<DownloadPreset> {
match quality {
"high" => Some(DownloadPreset {
video_bit_rate: 8_000_000,
max_height: 1080,
audio_bit_rate: 384_000,
}),
"medium" => Some(DownloadPreset {
video_bit_rate: 4_000_000,
max_height: 720,
audio_bit_rate: 256_000,
}),
"low" => Some(DownloadPreset {
video_bit_rate: 1_500_000,
max_height: 480,
audio_bit_rate: 128_000,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_presets_are_ordered_and_original_has_none() {
let high = download_preset("high").unwrap();
let medium = download_preset("medium").unwrap();
let low = download_preset("low").unwrap();
assert!(high.total_bit_rate() > medium.total_bit_rate());
assert!(medium.total_bit_rate() > low.total_bit_rate());
assert!(high.max_height > medium.max_height && medium.max_height > low.max_height);
assert_eq!(download_preset("original"), None);
assert_eq!(download_preset("nonsense"), None);
}
}