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