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}