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>
151 lines
4.7 KiB
Rust
151 lines
4.7 KiB
Rust
//! Download events for progress tracking and status updates
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Events emitted during download operations
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "camelCase")]
|
|
pub enum DownloadEvent {
|
|
/// Download has been queued
|
|
#[serde(rename_all = "camelCase")]
|
|
Queued { download_id: i64, item_id: String },
|
|
/// Download has started
|
|
#[serde(rename_all = "camelCase")]
|
|
Started { download_id: i64, item_id: String },
|
|
/// Download progress update
|
|
#[serde(rename_all = "camelCase")]
|
|
Progress {
|
|
download_id: i64,
|
|
item_id: String,
|
|
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")]
|
|
Completed {
|
|
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")]
|
|
Failed {
|
|
download_id: i64,
|
|
item_id: String,
|
|
error: String,
|
|
},
|
|
/// Download paused
|
|
#[serde(rename_all = "camelCase")]
|
|
Paused { download_id: i64, item_id: String },
|
|
/// Download cancelled
|
|
#[serde(rename_all = "camelCase")]
|
|
Cancelled { download_id: i64, item_id: String },
|
|
/// The queue is holding: WiFi-only is enabled and the current network is
|
|
/// metered/cellular. Pending rows stay pending and resume on network change.
|
|
///
|
|
/// TRACES: UR-053 | DR-074
|
|
WaitingForNetwork,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_download_event_serialization_roundtrip() {
|
|
let event = DownloadEvent::Progress {
|
|
download_id: 1,
|
|
item_id: "test123".to_string(),
|
|
bytes_downloaded: 1024,
|
|
total_bytes: Some(2048),
|
|
progress: 0.5,
|
|
estimated: false,
|
|
};
|
|
|
|
let json = serde_json::to_string(&event).unwrap();
|
|
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
|
|
|
|
match deserialized {
|
|
DownloadEvent::Progress {
|
|
download_id,
|
|
item_id,
|
|
progress,
|
|
..
|
|
} => {
|
|
assert_eq!(download_id, 1);
|
|
assert_eq!(item_id, "test123");
|
|
assert_eq!(progress, 0.5);
|
|
}
|
|
_ => panic!("Wrong variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_download_event_completed() {
|
|
let event = DownloadEvent::Completed {
|
|
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();
|
|
assert!(json.contains("\"type\":\"completed\""));
|
|
// Verify camelCase field names
|
|
assert!(
|
|
json.contains("\"downloadId\":42"),
|
|
"Expected downloadId (camelCase), got: {}",
|
|
json
|
|
);
|
|
assert!(
|
|
json.contains("\"itemId\":\"song456\""),
|
|
"Expected itemId (camelCase), got: {}",
|
|
json
|
|
);
|
|
assert!(
|
|
json.contains("\"filePath\":"),
|
|
"Expected filePath (camelCase), got: {}",
|
|
json
|
|
);
|
|
|
|
// Verify roundtrip
|
|
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
|
|
match deserialized {
|
|
DownloadEvent::Completed { file_path, .. } => {
|
|
assert_eq!(file_path, "/path/to/file.mp3");
|
|
}
|
|
_ => panic!("Wrong variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_download_event_failed() {
|
|
let event = DownloadEvent::Failed {
|
|
download_id: 10,
|
|
item_id: "failed_item".to_string(),
|
|
error: "Network timeout".to_string(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&event).unwrap();
|
|
assert!(json.contains("\"type\":\"failed\""));
|
|
|
|
// Verify roundtrip
|
|
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
|
|
match deserialized {
|
|
DownloadEvent::Failed { error, .. } => {
|
|
assert_eq!(error, "Network timeout");
|
|
}
|
|
_ => panic!("Wrong variant"),
|
|
}
|
|
}
|
|
}
|