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:
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user