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>
182 lines
7.0 KiB
Rust
182 lines
7.0 KiB
Rust
//! 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));
|
||
}
|
||
}
|