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:
2026-09-21 11:18:29 +02:00
co-authored by Claude Opus 5
parent e271874b1d
commit b3228cb4f4
15 changed files with 716 additions and 105 deletions
+99 -16
View File
@@ -517,6 +517,34 @@ pub(crate) async fn requeue_mistyped_video_downloads(
Ok(n)
}
/// What a resolver hands back for one queued row: the URL to fetch and, for a
/// video, the size predicted for it (see `download::estimate`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedDownloadUrl {
pub url: String,
pub expected_bytes: Option<u64>,
}
impl From<String> for ResolvedDownloadUrl {
/// An audio stream URL: served static, so the response states its own
/// length and nothing needs predicting.
fn from(url: String) -> Self {
Self {
url,
expected_bytes: None,
}
}
}
impl From<crate::repository::ResolvedVideoDownload> for ResolvedDownloadUrl {
fn from(r: crate::repository::ResolvedVideoDownload) -> Self {
Self {
url: r.url,
expected_bytes: r.expected_bytes,
}
}
}
/// Core of [`resume_queued_downloads`], factored out for testing: select every
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
/// `None` leaves the row pending), and heal the row so the pump can start it.
@@ -534,7 +562,7 @@ pub(crate) async fn resolve_pending_download_urls<F, Fut>(
) -> Result<ResumeQueuedResult, String>
where
F: Fn(String, String, String) -> Fut,
Fut: std::future::Future<Output = Option<String>>,
Fut: std::future::Future<Output = Option<ResolvedDownloadUrl>>,
{
if only_ids.is_some_and(|ids| ids.is_empty()) {
return Ok(ResumeQueuedResult {
@@ -600,8 +628,8 @@ where
let mut failed = 0usize;
for (download_id, item_id, media_type, quality) in rows {
let stream_url = match resolve(item_id.clone(), media_type, quality).await {
Some(url) => url,
let target = match resolve(item_id.clone(), media_type, quality).await {
Some(target) => target,
None => {
failed += 1;
continue;
@@ -609,13 +637,21 @@ where
};
// Heal the row so the pump can start it. Guard on stream_url IS NULL so a
// concurrent resolver doesn't clobber an already-started row.
// concurrent resolver doesn't clobber an already-started row. The
// predicted size, when there is one, gives the worker a progress total
// for a response that carries none (DR-290).
let expected = target
.expected_bytes
.and_then(|n| i64::try_from(n).ok())
.map_or(QueryParam::Null, QueryParam::Int64);
let update = Query::with_params(
"UPDATE downloads SET stream_url = ?, target_dir = ?
"UPDATE downloads SET stream_url = ?, target_dir = ?,
file_size = COALESCE(?, file_size)
WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
vec![
QueryParam::String(stream_url),
QueryParam::String(target.url),
QueryParam::String(target_dir.to_string()),
expected,
QueryParam::Int64(download_id),
],
);
@@ -705,17 +741,18 @@ pub async fn resume_queued_downloads(
async move {
if media_type == "video" {
Some(
crate::repository::resolve_video_download_url(
crate::repository::resolve_video_download(
repo.as_ref(),
&item_id,
&quality,
None,
)
.await,
.await
.into(),
)
} else {
match repo.get_audio_stream_url(&item_id).await {
Ok(url) => Some(url),
Ok(url) => Some(url.into()),
Err(e) => {
warn!(
"[Catalog] Failed to resolve audio URL for {}: {:?}",
@@ -807,6 +844,7 @@ mod tests {
target_dir TEXT,
media_type TEXT,
quality_preset TEXT,
file_size INTEGER,
progress REAL DEFAULT 0,
bytes_downloaded INTEGER DEFAULT 0,
started_at TEXT,
@@ -890,7 +928,7 @@ mod tests {
&db,
"/data/downloads",
None,
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}").into()) },
)
.await
.unwrap();
@@ -932,7 +970,7 @@ mod tests {
&db,
"/data",
Some(&[mine]),
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}").into()) },
)
.await
.unwrap();
@@ -962,7 +1000,7 @@ mod tests {
let out =
resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
Some(format!("http://resolved/{item_id}"))
Some(format!("http://resolved/{item_id}").into())
})
.await
.unwrap();
@@ -1015,7 +1053,7 @@ mod tests {
let seen = Arc::clone(&seen_c);
async move {
seen.lock_safe().push((item_id.clone(), media_type));
Some(format!("http://resolved/{item_id}"))
Some(format!("http://resolved/{item_id}").into())
}
})
.await
@@ -1048,7 +1086,7 @@ mod tests {
let seen = Arc::clone(&seen_c);
async move {
*seen.lock_safe() = media_type;
Some("http://x".to_string())
Some("http://x".to_string().into())
}
})
.await
@@ -1072,7 +1110,7 @@ mod tests {
let seen = Arc::clone(&seen_c);
async move {
*seen.lock_safe() = media_type;
Some("http://x".to_string())
Some("http://x".to_string().into())
}
})
.await
@@ -1123,6 +1161,51 @@ mod tests {
assert_eq!(status, "completed", "a correct video download is untouched");
}
/// A transcode answers with no `Content-Length`, so the worker's only
/// chance at a progress total is the size predicted at resolve time. That
/// prediction has to reach the row, and only where there is one — an
/// audio row's `None` must not null out a size the row already holds.
///
/// TRACES: UR-071 | DR-290 | UT-253
#[tokio::test]
async fn resolving_persists_the_predicted_size_without_erasing_a_known_one() {
let db = test_db();
insert_download(&db, "film", "pending", None, Some("video")).await;
insert_download(&db, "track", "pending", None, Some("audio")).await;
db.execute(Query::with_params(
"UPDATE downloads SET file_size = 777 WHERE item_id = ?",
vec![QueryParam::String("track".to_string())],
))
.await
.unwrap();
resolve_pending_download_urls(&db, "/data", None, |item_id, media_type, _q| async move {
Some(ResolvedDownloadUrl {
url: format!("http://resolved/{item_id}"),
expected_bytes: (media_type == "video").then_some(1_500_000_000),
})
})
.await
.unwrap();
let size = |item: &'static str| {
let db = Arc::clone(&db);
async move {
db.query_one(
Query::with_params(
"SELECT file_size FROM downloads WHERE item_id = ?",
vec![QueryParam::String(item.to_string())],
),
|row| row.get::<_, Option<i64>>(0),
)
.await
.unwrap()
}
};
assert_eq!(size("film").await, Some(1_500_000_000));
assert_eq!(size("track").await, Some(777));
}
#[tokio::test]
async fn video_rows_use_media_type_in_resolver() {
let db = test_db();
@@ -1134,7 +1217,7 @@ mod tests {
None,
|item_id, media_type, _q| async move {
assert_eq!(media_type, "video");
Some(format!("http://transcode/{item_id}"))
Some(format!("http://transcode/{item_id}").into())
},
)
.await
+36 -15
View File
@@ -759,7 +759,7 @@ pub async fn download_album(
async move {
use crate::repository::MediaRepository;
match repo.get_audio_stream_url(&item_id).await {
Ok(url) => Some(url),
Ok(url) => Some(url.into()),
Err(e) => {
warn!(
"[download_album] Failed to resolve stream URL for {}: {:?}",
@@ -1604,6 +1604,9 @@ pub async fn start_download(
item_id,
stream_url,
target_path,
file_size_from_server
.or(file_size)
.and_then(|n| u64::try_from(n).ok()),
active_downloads,
);
@@ -1703,15 +1706,20 @@ pub async fn enqueue_video_downloads(
// Build the download URL, resolving the source's audio codec first so a
// track this device cannot decode is re-encoded on the way down rather
// than saved as a silent file (DR-167).
let stream_url =
crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
let resolved =
crate::repository::resolve_video_download(repo.as_ref(), &item_id, &quality, None)
.await;
// The predicted size becomes the row's `file_size` so the worker has a
// total to report against when the response has none (DR-290). A
// size the server states later replaces it on completion.
let update_query = Query::with_params(
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ?, \
file_size = COALESCE(?, file_size) WHERE id = ?",
vec![
QueryParam::String(stream_url),
QueryParam::String(resolved.url),
QueryParam::String(target_dir.clone()),
expected_bytes_param(resolved.expected_bytes),
QueryParam::Int64(download_id),
],
);
@@ -1819,7 +1827,7 @@ pub(crate) async fn pump_download_queue(
// Find the next pending, startable download (has a stream URL). Exclude
// anything already registered as active to avoid double-starting.
let next_query = Query::with_params(
"SELECT id, item_id, file_path, stream_url, target_dir
"SELECT id, item_id, file_path, stream_url, target_dir, file_size
FROM downloads
WHERE status = 'pending'
AND stream_url IS NOT NULL
@@ -1828,7 +1836,7 @@ pub(crate) async fn pump_download_queue(
vec![],
);
let candidates: Vec<(i64, String, String, String, String)> = match db_service
let candidates: Vec<(i64, String, String, String, String, Option<i64>)> = match db_service
.query_many(next_query, |row| {
Ok((
row.get(0)?,
@@ -1836,6 +1844,7 @@ pub(crate) async fn pump_download_queue(
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
))
})
.await
@@ -1848,14 +1857,14 @@ pub(crate) async fn pump_download_queue(
};
// Pick the first candidate not already active.
let next = candidates.into_iter().find(|(id, _, _, _, _)| {
let next = candidates.into_iter().find(|(id, _, _, _, _, _)| {
active_downloads
.lock()
.map(|active| !active.contains(id))
.unwrap_or(false)
});
let (download_id, item_id, file_path, stream_url, target_dir) = match next {
let (download_id, item_id, file_path, stream_url, target_dir, file_size) = match next {
Some(n) => n,
None => return, // Nothing pending to start
};
@@ -1943,11 +1952,20 @@ pub(crate) async fn pump_download_queue(
item_id,
stream_url,
target_path,
file_size.and_then(|n| u64::try_from(n).ok()),
active_downloads.clone(),
);
}
}
/// A predicted size as a bind parameter: `NULL` keeps whatever the row holds.
fn expected_bytes_param(expected: Option<u64>) -> QueryParam {
match expected.and_then(|n| i64::try_from(n).ok()) {
Some(n) => QueryParam::Int64(n),
None => QueryParam::Null,
}
}
/// Spawn the background worker for one download. On completion or failure it
/// unregisters the slot, emits the terminal event, and pumps the queue so the
/// next pending download starts automatically.
@@ -1957,6 +1975,7 @@ fn spawn_download_worker(
item_id: String,
stream_url: String,
target_path: std::path::PathBuf,
expected_bytes: Option<u64>,
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
) {
use crate::download::events::DownloadEvent;
@@ -1975,18 +1994,19 @@ fn spawn_download_worker(
// Progress callback that emits events to the frontend
let progress_app = app.clone();
let progress_item_id = item_id.clone();
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
let progress = total_bytes
.filter(|&t| t > 0)
.map(|t| bytes_downloaded as f64 / t as f64)
.unwrap_or(0.0);
let on_progress = move |bytes_downloaded: u64, content_length: Option<u64>| {
// The server's length when it gave one; the prediction made at
// resolve time when it did not (a transcode). DR-290
let total = crate::download::estimate::progress_total(content_length, expected_bytes);
let progress = crate::download::estimate::progress_fraction(bytes_downloaded, total);
let event = DownloadEvent::Progress {
download_id,
item_id: progress_item_id.clone(),
bytes_downloaded: bytes_downloaded as i64,
total_bytes: total_bytes.map(|t| t as i64),
total_bytes: total.map(|t| t.bytes as i64),
progress,
estimated: total.is_some_and(|t| t.estimated),
};
let _ = progress_app.emit("download-event", event);
};
@@ -2060,6 +2080,7 @@ fn spawn_download_worker(
download_id,
item_id,
file_path,
bytes_downloaded: res.bytes_downloaded as i64,
};
match app.emit("download-event", completed_event) {
Ok(_) => debug!(" Completed event emitted successfully"),
+181
View File
@@ -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));
}
}
+10
View File
@@ -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();
+2
View File
@@ -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;
+66
View File
@@ -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);
}
}
+57 -21
View File
@@ -213,7 +213,7 @@ pub trait MediaRepository: Send + Sync {
/// [`resolve_video_download_url`] rather than calling it directly.
///
/// `source_audio_codec` is the codec of the audio track the server would
/// serve (see [`served_audio_codec`]); `None` when it is not known. At
/// serve (see [`resolve_video_download`]); `None` when it is not known. At
/// `original` quality it decides whether the file can be copied byte-for-byte
/// or has to have its audio re-encoded on the way down — a downloaded file is
/// played back with no server in reach, so it has to be decodable *here*.
@@ -345,35 +345,70 @@ pub trait MediaRepository: Send + Sync {
) -> Result<(), RepoError>;
}
/// The audio codec the server would serve for `item_id` — the default track, or
/// the first when none is marked, matching the track Jellyfin picks.
/// A video download, resolved: the URL to fetch and, where the item told us
/// enough, how many bytes to expect from it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedVideoDownload {
pub url: String,
/// Predicted size (see `download::estimate`), used as the progress total
/// when the response carries no `Content-Length` — a transcode never does.
pub expected_bytes: Option<u64>,
}
/// Resolve the download URL for a video, applying the audio-codec policy that
/// keeps the saved file playable offline (DR-171), and predict its size from
/// the same item lookup (DR-290).
///
/// `None` when the item has no audio, names no codec, or cannot be fetched. A
/// caller must read that as "unknown", never as "fine": it is the input to a
/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
/// exactly as it was.
/// Every video download goes through here rather than calling the builder
/// directly: the builder is pure and cannot look the codec up, and a caller that
/// forgets to is exactly how the silent downloads shipped.
///
/// TRACES: UR-071 | DR-171 | UT-166
pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
let item = repo.get_item(item_id).await.ok()?;
/// TRACES: UR-071 | DR-171, DR-290
pub async fn resolve_video_download(
repo: &dyn MediaRepository,
item_id: &str,
quality: &str,
media_source_id: Option<&str>,
) -> ResolvedVideoDownload {
let item = repo.get_item(item_id).await.ok();
let audio: Vec<(Option<&str>, bool)> = item
.media_streams
.as_deref()
.as_ref()
.and_then(|i| i.media_streams.as_deref())
.unwrap_or_default()
.iter()
.filter(|s| s.stream_type == "Audio")
.map(|s| (s.codec.as_deref(), s.is_default))
.collect();
// The default track, or the first when none is marked, matching the track
// Jellyfin picks. `None` reads as "unknown", never as "fine": it feeds a
// policy that only *adds* a transcode, so an unknown codec leaves behaviour
// exactly as it was. TRACES: UR-071 | DR-171 | UT-166
let codec = device_profile::served_audio_codec(&audio);
device_profile::served_audio_codec(&audio).map(str::to_string)
// The source that will be served: the one asked for, else the first —
// the same choice Jellyfin makes when no `mediaSourceId` is given.
let source_size = item.as_ref().and_then(|i| {
let sources = i.media_sources.as_deref()?;
let source = match media_source_id {
Some(id) => sources.iter().find(|s| s.id == id),
None => sources.first(),
};
source?.size
});
let expected_bytes = crate::download::estimate::expected_download_bytes(
quality,
item.as_ref().and_then(|i| i.runtime_ticks),
source_size,
);
ResolvedVideoDownload {
url: repo.get_video_download_url(item_id, quality, media_source_id, codec),
expected_bytes,
}
}
/// Resolve the download URL for a video, applying the audio-codec policy that
/// keeps the saved file playable offline (DR-171).
///
/// Every video download goes through here rather than calling the builder
/// directly: the builder is pure and cannot look the codec up, and a caller that
/// forgets to is exactly how the silent downloads shipped.
/// [`resolve_video_download`] for callers that only need the URL.
///
/// TRACES: UR-071 | DR-171
pub async fn resolve_video_download_url(
@@ -382,6 +417,7 @@ pub async fn resolve_video_download_url(
quality: &str,
media_source_id: Option<&str>,
) -> String {
let codec = served_audio_codec(repo, item_id).await;
repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
resolve_video_download(repo, item_id, quality, media_source_id)
.await
.url
}
+6 -22
View File
@@ -2520,27 +2520,11 @@ impl MediaRepository for OnlineRepository {
// fine in itself, but it also means a mis-typed cap degrades silently.
// Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
// video copy is gated by `allowVideoStreamCopy`.
match quality {
"high" => {
params.push("videoBitRate=8000000".to_string());
params.push("maxHeight=1080".to_string());
params.push("audioBitRate=384000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
params.push("allowVideoStreamCopy=false".to_string());
}
"medium" => {
params.push("videoBitRate=4000000".to_string());
params.push("maxHeight=720".to_string());
params.push("audioBitRate=256000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
params.push("allowVideoStreamCopy=false".to_string());
}
"low" => {
params.push("videoBitRate=1500000".to_string());
params.push("maxHeight=480".to_string());
params.push("audioBitRate=128000".to_string());
match crate::download::presets::download_preset(quality) {
Some(preset) => {
params.push(format!("videoBitRate={}", preset.video_bit_rate));
params.push(format!("maxHeight={}", preset.max_height));
params.push(format!("audioBitRate={}", preset.audio_bit_rate));
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
params.push("allowVideoStreamCopy=false".to_string());
@@ -2566,7 +2550,7 @@ impl MediaRepository for OnlineRepository {
// rather than applied to every `original` download.
//
// TRACES: UR-071, UR-004 | DR-171 | UT-166
_ => match source_audio_codec {
None => match source_audio_codec {
Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
params.push("videoCodec=h264".to_string());
params.push("allowVideoStreamCopy=true".to_string());