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