jellytau_lib/download/worker.rs
1//! Download worker for HTTP streaming with progress tracking and retry logic
2
3use log::warn;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::time::Duration;
6
7use futures_util::StreamExt;
8use tokio::fs;
9use tokio::io::AsyncWriteExt;
10
11use super::DownloadTask;
12
13/// Download worker that handles individual download tasks
14pub struct DownloadWorker {
15 /// HTTP client for downloads
16 client: reqwest::Client,
17 /// Maximum retry attempts
18 max_retries: u32,
19}
20
21impl DownloadWorker {
22 pub fn new() -> Self {
23 let client = reqwest::Client::builder()
24 .timeout(Duration::from_secs(300)) // 5 minute timeout
25 .https_only(true)
26 .build()
27 .expect("Failed to create HTTP client");
28
29 Self {
30 client,
31 max_retries: 3,
32 }
33 }
34
35 /// Download a file with retry logic and progress tracking.
36 ///
37 /// `stop` is the pause/cancel flag (see [`crate::download::stop`]). It is
38 /// checked between chunks and again between retries, so a paused download
39 /// stops promptly rather than after its next backoff — up to 45 seconds
40 /// away, which reads as the pause having done nothing.
41 ///
42 /// TRACES: UR-055 | DR-168
43 pub async fn download<F>(
44 &self,
45 task: &DownloadTask,
46 stop: &AtomicBool,
47 on_progress: F,
48 ) -> Result<DownloadResult, DownloadError>
49 where
50 F: Fn(u64, Option<u64>) + Send + Sync,
51 {
52 let mut retries = 0;
53
54 loop {
55 if stop.load(Ordering::SeqCst) {
56 return Err(DownloadError::Stopped);
57 }
58 match self.try_download(task, stop, &on_progress).await {
59 Ok(result) => return Ok(result),
60 Err(e) if retries < self.max_retries && e.is_retryable() => {
61 retries += 1;
62 let delay = Self::exponential_backoff(retries);
63 warn!(
64 "Download failed (attempt {}/{}), retrying in {:?}: {}",
65 retries, self.max_retries, delay, e
66 );
67 tokio::time::sleep(delay).await;
68 }
69 Err(e) => return Err(e),
70 }
71 }
72 }
73
74 /// Attempt a single download
75 async fn try_download<F>(
76 &self,
77 task: &DownloadTask,
78 stop: &AtomicBool,
79 on_progress: &F,
80 ) -> Result<DownloadResult, DownloadError>
81 where
82 F: Fn(u64, Option<u64>) + Send + Sync,
83 {
84 // Create parent directories
85 if let Some(parent) = task.target_path.parent() {
86 fs::create_dir_all(parent)
87 .await
88 .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
89 }
90
91 // Check for partial download
92 let temp_path = partial_path(&task.target_path);
93 let existing_bytes = if temp_path.exists() {
94 fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
95 } else {
96 0
97 };
98
99 // Build HTTP request with Range header for resume support
100 let mut request = self.client.get(&task.url);
101 if existing_bytes > 0 {
102 request = request.header("Range", format!("bytes={}-", existing_bytes));
103 }
104
105 // Send request
106 let response = request
107 .send()
108 .await
109 .map_err(|e| DownloadError::Network(e.to_string()))?;
110
111 // Check status
112 if !response.status().is_success() && response.status().as_u16() != 206 {
113 return Err(DownloadError::Http(response.status().as_u16()));
114 }
115
116 // Did the server actually honour the Range? A transcode does not, and
117 // answers 200 with the whole stream — appending that would duplicate what
118 // we already hold. (DR-170)
119 let resume_from = resume_offset(existing_bytes, response.status().as_u16());
120 if existing_bytes > 0 && resume_from == 0 {
121 warn!(
122 "Server ignored the Range request (HTTP {}) — restarting {} from the beginning \
123 instead of appending to {} existing bytes",
124 response.status().as_u16(),
125 task.target_path.display(),
126 existing_bytes
127 );
128 }
129
130 // Get content length. Absent on a chunked transcode, which is why progress
131 // for a non-`original` preset has no percentage to show.
132 let _total_bytes = response
133 .headers()
134 .get(reqwest::header::CONTENT_LENGTH)
135 .and_then(|v| v.to_str().ok())
136 .and_then(|v| v.parse::<u64>().ok())
137 .map(|len| len + resume_from);
138
139 // Append only when resuming a range the server agreed to; otherwise
140 // create/truncate so the restarted stream replaces the stale bytes.
141 let mut file = if resume_from > 0 {
142 fs::OpenOptions::new().append(true).open(&temp_path).await
143 } else {
144 fs::File::create(&temp_path).await
145 }
146 .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
147
148 // Stream download with progress tracking
149 let mut downloaded = resume_from;
150 let mut stream = response.bytes_stream();
151 let mut last_progress_emit = std::time::Instant::now();
152
153 while let Some(chunk) = stream.next().await {
154 // Checked before writing, so a paused download stops on a byte
155 // boundary the `.part` file already accounts for — the Range request
156 // on resume then asks for exactly what is missing. Flushing what we
157 // have and leaving the file in place is the whole mechanism behind
158 // "resume", so this must never delete it. (DR-168)
159 if stop.load(Ordering::SeqCst) {
160 let _ = file.flush().await;
161 let _ = file.sync_all().await;
162 return Err(DownloadError::Stopped);
163 }
164
165 let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
166
167 file.write_all(&chunk)
168 .await
169 .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
170
171 downloaded += chunk.len() as u64;
172
173 // Emit progress every 500ms or every MB
174 if last_progress_emit.elapsed() > Duration::from_millis(500)
175 || downloaded.is_multiple_of(1024 * 1024)
176 {
177 last_progress_emit = std::time::Instant::now();
178 on_progress(downloaded, _total_bytes);
179 }
180 }
181
182 file.sync_all()
183 .await
184 .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
185
186 // A media file is never legitimately empty, and completing one is worse
187 // than failing: the row goes `completed`, the item shows as available
188 // offline, and playback then stalls on a file with nothing in it. A
189 // server that answered 200 with no body — an error page, a transcode
190 // that produced nothing — used to land here. Treat it as the network
191 // failure it is so the retry budget applies and the `.part` is kept.
192 if let Some(reason) = rejects_as_empty(downloaded) {
193 return Err(DownloadError::Network(reason.to_string()));
194 }
195
196 // Move from .part to final location
197 fs::rename(&temp_path, &task.target_path)
198 .await
199 .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
200
201 Ok(DownloadResult {
202 bytes_downloaded: downloaded,
203 })
204 }
205
206 /// Calculate exponential backoff delay
207 fn exponential_backoff(retry_count: u32) -> Duration {
208 let base_delay = 5; // 5 seconds
209 let delay_secs = base_delay * 3u64.pow(retry_count - 1); // 5s, 15s, 45s
210 Duration::from_secs(delay_secs)
211 }
212}
213
214/// Where to resume writing a partial download, given how the server answered.
215///
216/// A byte offset of 0 means "start the file again"; anything else means "append
217/// from here".
218///
219/// This is what makes non-`original` downloads survive. Those presets ask
220/// Jellyfin to **transcode**, and a live transcode is chunked with no
221/// `Content-Length` and cannot be byte-seeked: the server ignores `Range` and
222/// answers `200` with the whole stream from the beginning, not `206` with the
223/// requested tail. The worker sent the header and appended the body regardless,
224/// so every retry — and every resume — concatenated a fresh copy of the whole
225/// transcode onto the bytes already on disk. The file grew past its real size
226/// and would not play. Only a `206` actually promises the tail; a `200` means we
227/// must discard what we have and take the stream from the top.
228///
229/// TRACES: UR-071 | DR-170
230pub fn resume_offset(existing_bytes: u64, status: u16) -> u64 {
231 if existing_bytes == 0 {
232 return 0;
233 }
234 // 206 Partial Content is the only answer that honours the Range request.
235 if status == 206 {
236 existing_bytes
237 } else {
238 0
239 }
240}
241
242/// Why a finished transfer must not be accepted, if it must not be.
243///
244/// A media file is never legitimately empty, and *completing* an empty one is
245/// worse than failing: the row goes `completed`, the item shows as available
246/// offline, and playback later stalls on a file with nothing in it. A server
247/// that answered 200 with no body — an error page, a transcode that produced
248/// nothing — used to land exactly there.
249///
250/// Reported as a network error so the existing retry budget applies and the
251/// `.part` file is kept for a resume.
252///
253/// TRACES: UR-019 | DR-168 | UT-168
254pub fn rejects_as_empty(downloaded: u64) -> Option<&'static str> {
255 (downloaded == 0)
256 .then_some("server sent an empty body; refusing to complete a zero-byte download")
257}
258
259/// The partial-download sidecar for `target`.
260///
261/// **Appends** `.part` rather than replacing the extension. The worker used
262/// `Path::with_extension("part")`, which replaces: `movie.mp4` became
263/// `movie.part`. Every cleanup path meanwhile deleted `"{file_path}.part"` —
264/// `movie.mp4.part` — so nothing ever matched and the partial file of every
265/// cancelled or failed download was left on disk forever, invisible to the
266/// disk-usage totals because no `downloads` row pointed at it. That is the
267/// reported "failure is not cleaned".
268///
269/// Appending also removes a collision the old form had: `movie.mp4` and
270/// `movie.mkv` both mapped to `movie.part` and would have fought over one file.
271///
272/// One function so the writer and the cleaners cannot disagree again.
273///
274/// TRACES: UR-055 | DR-169
275pub fn partial_path(target: &std::path::Path) -> std::path::PathBuf {
276 let mut s = target.as_os_str().to_os_string();
277 s.push(".part");
278 std::path::PathBuf::from(s)
279}
280
281/// Result of a successful download
282#[derive(Debug)]
283pub struct DownloadResult {
284 pub bytes_downloaded: u64,
285}
286
287/// Download error types
288#[derive(Debug)]
289pub enum DownloadError {
290 Network(String),
291 Http(u16),
292 FileSystem(String),
293 /// The download was asked to stop (paused or cancelled). Not a failure: the
294 /// row's status already says what happened, and the partial file is kept so a
295 /// resume can continue from it.
296 Stopped,
297}
298
299impl DownloadError {
300 /// Check if this error is retryable
301 fn is_retryable(&self) -> bool {
302 match self {
303 DownloadError::Network(_) => true,
304 DownloadError::Http(status) => *status >= 500, // Retry server errors
305 DownloadError::FileSystem(_) => false,
306 // Retrying would restart the very download the user just paused.
307 DownloadError::Stopped => false,
308 }
309 }
310
311 /// Whether this outcome means "the user stopped it", rather than a failure to
312 /// record and report.
313 pub fn is_stopped(&self) -> bool {
314 matches!(self, DownloadError::Stopped)
315 }
316}
317
318impl std::fmt::Display for DownloadError {
319 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320 match self {
321 DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
322 DownloadError::Http(status) => write!(f, "HTTP error {}", status),
323 DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
324 DownloadError::Stopped => write!(f, "Download stopped by request"),
325 }
326 }
327}
328
329impl std::error::Error for DownloadError {}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 /// A transfer that produced no bytes must never be marked complete.
336 ///
337 /// Completing it publishes an empty file as playable offline; the media
338 /// server then answers a request for it with a 416 and the item simply
339 /// never starts, with nothing in the UI explaining why.
340 ///
341 /// TRACES: UR-019 | DR-168 | UT-168
342 #[test]
343 fn test_a_zero_byte_transfer_is_rejected_rather_than_completed() {
344 assert!(
345 rejects_as_empty(0).is_some(),
346 "a zero-byte download must not be completed"
347 );
348 assert!(rejects_as_empty(1).is_none());
349 assert!(rejects_as_empty(4 * 1024 * 1024).is_none());
350 }
351
352 /// The bitrate-download corruption: a transcode ignores `Range` and answers
353 /// `200` with the whole stream. Appending that to the bytes already on disk
354 /// duplicated them, so every retry grew the file past its real size and left
355 /// it unplayable. Only `206` promises the requested tail.
356 ///
357 /// TRACES: UR-071 | DR-170 | UT-164
358 #[test]
359 fn test_resume_offset_only_appends_when_the_server_honoured_the_range() {
360 // Nothing on disk: start at the beginning either way.
361 assert_eq!(resume_offset(0, 200), 0);
362 assert_eq!(resume_offset(0, 206), 0);
363
364 // The server agreed to the range — append to what we have.
365 assert_eq!(resume_offset(5_000, 206), 5_000);
366
367 // The server ignored it and is sending the whole file (a transcode).
368 // Restart, or the bytes are duplicated.
369 assert_eq!(
370 resume_offset(5_000, 200),
371 0,
372 "a 200 carries the whole stream; appending it corrupts the file"
373 );
374 }
375
376 /// The regression: `with_extension` replaced the extension, so the worker
377 /// wrote `movie.part` while every cleanup path deleted `movie.mp4.part`.
378 /// Nothing matched, and partial files accumulated forever.
379 ///
380 /// TRACES: UR-055 | DR-169 | UT-163
381 #[test]
382 fn test_partial_path_appends_rather_than_replacing_the_extension() {
383 use std::path::Path;
384
385 assert_eq!(
386 partial_path(Path::new("/media/movie.mp4")),
387 Path::new("/media/movie.mp4.part"),
388 "the cleanup paths delete \"{{file_path}}.part\"; this must produce it"
389 );
390
391 // Two sources for one title must not fight over a single partial file.
392 assert_ne!(
393 partial_path(Path::new("/media/movie.mp4")),
394 partial_path(Path::new("/media/movie.mkv")),
395 );
396
397 // Extension-less targets still get a sidecar rather than being clobbered.
398 assert_eq!(
399 partial_path(Path::new("/media/track")),
400 Path::new("/media/track.part"),
401 );
402
403 // A dotted name keeps every part of its own name.
404 assert_eq!(
405 partial_path(Path::new("/media/S01.E02.episode.mkv")),
406 Path::new("/media/S01.E02.episode.mkv.part"),
407 );
408 }
409
410 #[test]
411 fn test_exponential_backoff() {
412 assert_eq!(
413 DownloadWorker::exponential_backoff(1),
414 Duration::from_secs(5)
415 );
416 assert_eq!(
417 DownloadWorker::exponential_backoff(2),
418 Duration::from_secs(15)
419 );
420 assert_eq!(
421 DownloadWorker::exponential_backoff(3),
422 Duration::from_secs(45)
423 );
424 }
425
426 #[test]
427 fn test_error_retryable() {
428 assert!(DownloadError::Network("timeout".to_string()).is_retryable());
429 assert!(DownloadError::Http(500).is_retryable());
430 assert!(DownloadError::Http(503).is_retryable());
431 assert!(!DownloadError::Http(404).is_retryable());
432 assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
433 // Retrying a paused download would restart what the user just stopped.
434 assert!(!DownloadError::Stopped.is_retryable());
435 assert!(DownloadError::Stopped.is_stopped());
436 assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
437 }
438}