Skip to main content

jellytau_lib/download/
estimate.rs

1//! How big a download is going to be when the server will not say.
2//!
3//! A direct copy answers with `Content-Length`, and the worker reports exact
4//! progress from it. A transcode is produced as it is sent — chunked, with no
5//! length — and the worker used to report `progress: 0.0` for its whole
6//! duration: an empty bar and "0%" while the byte count climbed for an hour.
7//! That is the case every film whose audio must be re-encoded lands in.
8//!
9//! The backend does know enough to estimate. It fetches the item to decide the
10//! audio policy anyway, and that item carries the source's size and runtime;
11//! the preset it chose fixes the bitrate. So the estimate is made where the
12//! URL is, persisted on the row as its `file_size`, and used only as a
13//! fallback: a real `Content-Length` always wins, and an estimated bar never
14//! claims completion.
15//!
16//! TRACES: UR-071 | DR-290
17
18use super::presets::download_preset;
19
20/// Ticks per second in Jellyfin's runtime unit.
21const TICKS_PER_SECOND: u64 = 10_000_000;
22
23/// The progress bar never reports more than this from an estimate, so a source
24/// that encodes a little larger than predicted shows 99% until the last byte
25/// rather than 104% — completion is the worker's to announce.
26pub const ESTIMATED_PROGRESS_CEILING: f64 = 0.99;
27
28/// The size a download for `quality` is expected to produce, in bytes.
29///
30/// - A preset re-encodes both streams at fixed rates, so the size is rate ×
31///   runtime. Jellyfin encodes to a target bitrate (`-b:v` with `-maxrate`), so
32///   the average lands near the cap rather than well under it.
33/// - `original` copies the picture and at most re-encodes the audio, so the
34///   output is the source's size give or take the audio track — and when no
35///   transcode is needed at all it is exactly the source's size.
36///
37/// `None` when the inputs needed are missing; the caller then has no total and
38/// the bar is indeterminate, which is honest and was the status quo.
39///
40/// TRACES: UR-071 | DR-290 | UT-252
41pub fn expected_download_bytes(
42    quality: &str,
43    runtime_ticks: Option<i64>,
44    source_size: Option<i64>,
45) -> Option<u64> {
46    match download_preset(quality) {
47        Some(preset) => {
48            let seconds = u64::try_from(runtime_ticks?).ok()? / TICKS_PER_SECOND;
49            (seconds > 0).then(|| preset.total_bit_rate() / 8 * seconds)
50        }
51        None => source_size
52            .and_then(|s| u64::try_from(s).ok())
53            .filter(|&s| s > 0),
54    }
55}
56
57/// What the progress bar measures against.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct ProgressTotal {
60    pub bytes: u64,
61    /// The total is a prediction, not the server's word.
62    pub estimated: bool,
63}
64
65/// The total to report progress against, given what the response said and what
66/// was predicted before it was made. The server's `Content-Length` always
67/// wins; the estimate fills in only when the server sent none.
68///
69/// TRACES: UR-071 | DR-290 | UT-252
70pub fn progress_total(content_length: Option<u64>, expected: Option<u64>) -> Option<ProgressTotal> {
71    match (
72        content_length.filter(|&n| n > 0),
73        expected.filter(|&n| n > 0),
74    ) {
75        (Some(bytes), _) => Some(ProgressTotal {
76            bytes,
77            estimated: false,
78        }),
79        (None, Some(bytes)) => Some(ProgressTotal {
80            bytes,
81            estimated: true,
82        }),
83        (None, None) => None,
84    }
85}
86
87/// The fraction complete, in `0.0..=1.0`. An estimated total is capped at
88/// [`ESTIMATED_PROGRESS_CEILING`] so a prediction that ran low never shows a
89/// finished bar on a download still running.
90///
91/// TRACES: UR-071 | DR-290 | UT-252
92pub fn progress_fraction(downloaded: u64, total: Option<ProgressTotal>) -> f64 {
93    let Some(total) = total else { return 0.0 };
94    let fraction = downloaded as f64 / total.bytes as f64;
95    let ceiling = if total.estimated {
96        ESTIMATED_PROGRESS_CEILING
97    } else {
98        1.0
99    };
100    fraction.clamp(0.0, ceiling)
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    const HOUR_TICKS: i64 = 3600 * TICKS_PER_SECOND as i64;
108
109    /// A transcode has no `Content-Length`, and this is the case that showed
110    /// "0%" for its whole duration: with a prediction in hand the bar must move.
111    ///
112    /// TRACES: UR-071 | DR-290 | UT-252
113    #[test]
114    fn test_estimate_fills_in_when_the_server_sent_no_length() {
115        let total = progress_total(None, Some(4_000));
116        assert_eq!(
117            total,
118            Some(ProgressTotal {
119                bytes: 4_000,
120                estimated: true
121            })
122        );
123        let fraction = progress_fraction(1_000, total);
124        assert!((fraction - 0.25).abs() < 1e-9, "got {fraction}");
125    }
126
127    /// The server's own figure is never second-guessed by a prediction.
128    #[test]
129    fn test_content_length_wins_over_the_estimate() {
130        let total = progress_total(Some(10_000), Some(4_000)).unwrap();
131        assert_eq!(total.bytes, 10_000);
132        assert!(!total.estimated);
133        assert_eq!(progress_fraction(10_000, Some(total)), 1.0);
134    }
135
136    /// A prediction that ran low must not announce completion: that is the
137    /// worker's to do when the last byte lands.
138    #[test]
139    fn test_estimated_progress_never_reaches_one() {
140        let total = progress_total(None, Some(1_000));
141        assert_eq!(progress_fraction(1_200, total), ESTIMATED_PROGRESS_CEILING);
142        assert_eq!(progress_fraction(0, total), 0.0);
143    }
144
145    /// Nothing known → nothing claimed, and a zero length is "nothing known".
146    #[test]
147    fn test_no_total_means_no_progress_claim() {
148        assert_eq!(progress_total(None, None), None);
149        assert_eq!(progress_total(Some(0), Some(0)), None);
150        assert_eq!(progress_fraction(500, None), 0.0);
151    }
152
153    /// A preset's size is its combined rate over the runtime — one hour of the
154    /// medium preset (4 Mb/s + 256 kb/s) is about 1.9 GB.
155    #[test]
156    fn test_preset_estimate_is_rate_times_runtime() {
157        let bytes = expected_download_bytes("medium", Some(HOUR_TICKS), Some(9_999)).unwrap();
158        assert_eq!(bytes, (4_000_000 + 256_000) / 8 * 3600);
159        // Without a runtime there is nothing to multiply.
160        assert_eq!(expected_download_bytes("medium", None, Some(9_999)), None);
161        assert_eq!(expected_download_bytes("medium", Some(0), None), None);
162    }
163
164    /// `original` copies the picture, so the source's size is the prediction —
165    /// with or without the audio being re-encoded on the way.
166    #[test]
167    fn test_original_estimate_is_the_source_size() {
168        assert_eq!(
169            expected_download_bytes("original", Some(HOUR_TICKS), Some(3_000_000_000)),
170            Some(3_000_000_000)
171        );
172        assert_eq!(
173            expected_download_bytes("original", Some(HOUR_TICKS), None),
174            None
175        );
176        assert_eq!(expected_download_bytes("original", None, Some(0)), None);
177        // An unknown quality name is treated as original by the URL builder,
178        // so it is here too.
179        assert_eq!(expected_download_bytes("wat", None, Some(10)), Some(10));
180    }
181}