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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user