//! 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, source_size: Option, ) -> Option { 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, expected: Option) -> Option { 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) -> 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)); } }