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
21/// How long a transfer may go without receiving a single byte before it is
22/// treated as dead and retried. Generous because a transcode download waits on
23/// ffmpeg, which pauses when Jellyfin throttles it.
24const STALL_TIMEOUT: Duration = Duration::from_secs(60);
25
26/// How long to wait for the TCP/TLS handshake before giving up.
27const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
28
29impl DownloadWorker {
30 pub fn new() -> Self {
31 Self::with_stall_timeout(STALL_TIMEOUT, true)
32 }
33
34 /// Build a worker whose HTTP client gives up on a transfer that receives
35 /// nothing for `stall`. `https_only` is relaxed only by tests, which serve
36 /// from a loopback socket.
37 ///
38 /// The timeouts are a *connect* timeout and a *read* timeout — never
39 /// `Client::timeout`. That one is a total deadline that runs until the body
40 /// has finished, and it was set to five minutes: every download longer than
41 /// that was cut off mid-body with "error decoding response body", then
42 /// retried. A transcode ignores `Range`, so each retry restarted from byte
43 /// zero, ran into the same five minutes, and after three attempts the
44 /// download failed — which is why no feature film at transcode speed ever
45 /// completed on a device that needs the audio re-encoded. A read timeout
46 /// resets on every chunk, so it catches a dead connection without putting a
47 /// ceiling on how long a healthy transfer may run.
48 ///
49 /// TRACES: UR-071 | DR-289 | UT-251
50 fn with_stall_timeout(stall: Duration, https_only: bool) -> Self {
51 let client = reqwest::Client::builder()
52 .connect_timeout(CONNECT_TIMEOUT)
53 .read_timeout(stall)
54 .https_only(https_only)
55 .build()
56 .expect("Failed to create HTTP client");
57
58 Self {
59 client,
60 max_retries: 3,
61 }
62 }
63
64 /// Download a file with retry logic and progress tracking.
65 ///
66 /// `stop` is the pause/cancel flag (see [`crate::download::stop`]). It is
67 /// checked between chunks and again between retries, so a paused download
68 /// stops promptly rather than after its next backoff — up to 45 seconds
69 /// away, which reads as the pause having done nothing.
70 ///
71 /// TRACES: UR-055 | DR-168
72 pub async fn download<F>(
73 &self,
74 task: &DownloadTask,
75 stop: &AtomicBool,
76 on_progress: F,
77 ) -> Result<DownloadResult, DownloadError>
78 where
79 F: Fn(u64, Option<u64>) + Send + Sync,
80 {
81 let mut retries = 0;
82
83 loop {
84 if stop.load(Ordering::SeqCst) {
85 return Err(DownloadError::Stopped);
86 }
87 match self.try_download(task, stop, &on_progress).await {
88 Ok(result) => return Ok(result),
89 Err(e) if retries < self.max_retries && e.is_retryable() => {
90 retries += 1;
91 let delay = Self::exponential_backoff(retries);
92 warn!(
93 "Download failed (attempt {}/{}), retrying in {:?}: {}",
94 retries, self.max_retries, delay, e
95 );
96 tokio::time::sleep(delay).await;
97 }
98 Err(e) => return Err(e),
99 }
100 }
101 }
102
103 /// Attempt a single download
104 async fn try_download<F>(
105 &self,
106 task: &DownloadTask,
107 stop: &AtomicBool,
108 on_progress: &F,
109 ) -> Result<DownloadResult, DownloadError>
110 where
111 F: Fn(u64, Option<u64>) + Send + Sync,
112 {
113 // Create parent directories
114 if let Some(parent) = task.target_path.parent() {
115 fs::create_dir_all(parent)
116 .await
117 .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
118 }
119
120 // Check for partial download
121 let temp_path = partial_path(&task.target_path);
122 let existing_bytes = if temp_path.exists() {
123 fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
124 } else {
125 0
126 };
127
128 // Build HTTP request with Range header for resume support
129 let mut request = self.client.get(&task.url);
130 if existing_bytes > 0 {
131 request = request.header("Range", format!("bytes={}-", existing_bytes));
132 }
133
134 // Send request
135 let response = request
136 .send()
137 .await
138 .map_err(|e| DownloadError::Network(e.to_string()))?;
139
140 // Check status
141 if !response.status().is_success() && response.status().as_u16() != 206 {
142 return Err(DownloadError::Http(response.status().as_u16()));
143 }
144
145 // Did the server actually honour the Range? A transcode does not, and
146 // answers 200 with the whole stream — appending that would duplicate what
147 // we already hold. (DR-170)
148 let resume_from = resume_offset(existing_bytes, response.status().as_u16());
149 if existing_bytes > 0 && resume_from == 0 {
150 warn!(
151 "Server ignored the Range request (HTTP {}) — restarting {} from the beginning \
152 instead of appending to {} existing bytes",
153 response.status().as_u16(),
154 task.target_path.display(),
155 existing_bytes
156 );
157 }
158
159 // Get content length. Absent on a chunked transcode, which is why progress
160 // for a non-`original` preset has no percentage to show.
161 let _total_bytes = response
162 .headers()
163 .get(reqwest::header::CONTENT_LENGTH)
164 .and_then(|v| v.to_str().ok())
165 .and_then(|v| v.parse::<u64>().ok())
166 .map(|len| len + resume_from);
167
168 // Append only when resuming a range the server agreed to; otherwise
169 // create/truncate so the restarted stream replaces the stale bytes.
170 let mut file = if resume_from > 0 {
171 fs::OpenOptions::new().append(true).open(&temp_path).await
172 } else {
173 fs::File::create(&temp_path).await
174 }
175 .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
176
177 // Stream download with progress tracking
178 let mut downloaded = resume_from;
179 let mut stream = response.bytes_stream();
180 let mut last_progress_emit = std::time::Instant::now();
181
182 while let Some(chunk) = stream.next().await {
183 // Checked before writing, so a paused download stops on a byte
184 // boundary the `.part` file already accounts for — the Range request
185 // on resume then asks for exactly what is missing. Flushing what we
186 // have and leaving the file in place is the whole mechanism behind
187 // "resume", so this must never delete it. (DR-168)
188 if stop.load(Ordering::SeqCst) {
189 let _ = file.flush().await;
190 let _ = file.sync_all().await;
191 return Err(DownloadError::Stopped);
192 }
193
194 let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
195
196 file.write_all(&chunk)
197 .await
198 .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
199
200 downloaded += chunk.len() as u64;
201
202 // Emit progress every 500ms or every MB
203 if last_progress_emit.elapsed() > Duration::from_millis(500)
204 || downloaded.is_multiple_of(1024 * 1024)
205 {
206 last_progress_emit = std::time::Instant::now();
207 on_progress(downloaded, _total_bytes);
208 }
209 }
210
211 file.sync_all()
212 .await
213 .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
214
215 // A media file is never legitimately empty, and completing one is worse
216 // than failing: the row goes `completed`, the item shows as available
217 // offline, and playback then stalls on a file with nothing in it. A
218 // server that answered 200 with no body — an error page, a transcode
219 // that produced nothing — used to land here. Treat it as the network
220 // failure it is so the retry budget applies and the `.part` is kept.
221 if let Some(reason) = rejects_as_empty(downloaded) {
222 return Err(DownloadError::Network(reason.to_string()));
223 }
224
225 // Move from .part to final location
226 fs::rename(&temp_path, &task.target_path)
227 .await
228 .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
229
230 Ok(DownloadResult {
231 bytes_downloaded: downloaded,
232 })
233 }
234
235 /// Calculate exponential backoff delay
236 fn exponential_backoff(retry_count: u32) -> Duration {
237 let base_delay = 5; // 5 seconds
238 let delay_secs = base_delay * 3u64.pow(retry_count - 1); // 5s, 15s, 45s
239 Duration::from_secs(delay_secs)
240 }
241}
242
243/// Where to resume writing a partial download, given how the server answered.
244///
245/// A byte offset of 0 means "start the file again"; anything else means "append
246/// from here".
247///
248/// This is what makes non-`original` downloads survive. Those presets ask
249/// Jellyfin to **transcode**, and a live transcode is chunked with no
250/// `Content-Length` and cannot be byte-seeked: the server ignores `Range` and
251/// answers `200` with the whole stream from the beginning, not `206` with the
252/// requested tail. The worker sent the header and appended the body regardless,
253/// so every retry — and every resume — concatenated a fresh copy of the whole
254/// transcode onto the bytes already on disk. The file grew past its real size
255/// and would not play. Only a `206` actually promises the tail; a `200` means we
256/// must discard what we have and take the stream from the top.
257///
258/// TRACES: UR-071 | DR-170
259pub fn resume_offset(existing_bytes: u64, status: u16) -> u64 {
260 if existing_bytes == 0 {
261 return 0;
262 }
263 // 206 Partial Content is the only answer that honours the Range request.
264 if status == 206 {
265 existing_bytes
266 } else {
267 0
268 }
269}
270
271/// Why a finished transfer must not be accepted, if it must not be.
272///
273/// A media file is never legitimately empty, and *completing* an empty one is
274/// worse than failing: the row goes `completed`, the item shows as available
275/// offline, and playback later stalls on a file with nothing in it. A server
276/// that answered 200 with no body — an error page, a transcode that produced
277/// nothing — used to land exactly there.
278///
279/// Reported as a network error so the existing retry budget applies and the
280/// `.part` file is kept for a resume.
281///
282/// TRACES: UR-019 | DR-168 | UT-168
283pub fn rejects_as_empty(downloaded: u64) -> Option<&'static str> {
284 (downloaded == 0)
285 .then_some("server sent an empty body; refusing to complete a zero-byte download")
286}
287
288/// The partial-download sidecar for `target`.
289///
290/// **Appends** `.part` rather than replacing the extension. The worker used
291/// `Path::with_extension("part")`, which replaces: `movie.mp4` became
292/// `movie.part`. Every cleanup path meanwhile deleted `"{file_path}.part"` —
293/// `movie.mp4.part` — so nothing ever matched and the partial file of every
294/// cancelled or failed download was left on disk forever, invisible to the
295/// disk-usage totals because no `downloads` row pointed at it. That is the
296/// reported "failure is not cleaned".
297///
298/// Appending also removes a collision the old form had: `movie.mp4` and
299/// `movie.mkv` both mapped to `movie.part` and would have fought over one file.
300///
301/// One function so the writer and the cleaners cannot disagree again.
302///
303/// TRACES: UR-055 | DR-169
304pub fn partial_path(target: &std::path::Path) -> std::path::PathBuf {
305 let mut s = target.as_os_str().to_os_string();
306 s.push(".part");
307 std::path::PathBuf::from(s)
308}
309
310/// Result of a successful download
311#[derive(Debug)]
312pub struct DownloadResult {
313 pub bytes_downloaded: u64,
314}
315
316/// Download error types
317#[derive(Debug)]
318pub enum DownloadError {
319 Network(String),
320 Http(u16),
321 FileSystem(String),
322 /// The download was asked to stop (paused or cancelled). Not a failure: the
323 /// row's status already says what happened, and the partial file is kept so a
324 /// resume can continue from it.
325 Stopped,
326}
327
328impl DownloadError {
329 /// Check if this error is retryable
330 fn is_retryable(&self) -> bool {
331 match self {
332 DownloadError::Network(_) => true,
333 DownloadError::Http(status) => *status >= 500, // Retry server errors
334 DownloadError::FileSystem(_) => false,
335 // Retrying would restart the very download the user just paused.
336 DownloadError::Stopped => false,
337 }
338 }
339
340 /// Whether this outcome means "the user stopped it", rather than a failure to
341 /// record and report.
342 pub fn is_stopped(&self) -> bool {
343 matches!(self, DownloadError::Stopped)
344 }
345}
346
347impl std::fmt::Display for DownloadError {
348 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349 match self {
350 DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
351 DownloadError::Http(status) => write!(f, "HTTP error {}", status),
352 DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
353 DownloadError::Stopped => write!(f, "Download stopped by request"),
354 }
355 }
356}
357
358impl std::error::Error for DownloadError {}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 /// A transfer that produced no bytes must never be marked complete.
365 ///
366 /// Completing it publishes an empty file as playable offline; the media
367 /// server then answers a request for it with a 416 and the item simply
368 /// never starts, with nothing in the UI explaining why.
369 ///
370 /// TRACES: UR-019 | DR-168 | UT-168
371 #[test]
372 fn test_a_zero_byte_transfer_is_rejected_rather_than_completed() {
373 assert!(
374 rejects_as_empty(0).is_some(),
375 "a zero-byte download must not be completed"
376 );
377 assert!(rejects_as_empty(1).is_none());
378 assert!(rejects_as_empty(4 * 1024 * 1024).is_none());
379 }
380
381 /// The bitrate-download corruption: a transcode ignores `Range` and answers
382 /// `200` with the whole stream. Appending that to the bytes already on disk
383 /// duplicated them, so every retry grew the file past its real size and left
384 /// it unplayable. Only `206` promises the requested tail.
385 ///
386 /// TRACES: UR-071 | DR-170 | UT-164
387 #[test]
388 fn test_resume_offset_only_appends_when_the_server_honoured_the_range() {
389 // Nothing on disk: start at the beginning either way.
390 assert_eq!(resume_offset(0, 200), 0);
391 assert_eq!(resume_offset(0, 206), 0);
392
393 // The server agreed to the range — append to what we have.
394 assert_eq!(resume_offset(5_000, 206), 5_000);
395
396 // The server ignored it and is sending the whole file (a transcode).
397 // Restart, or the bytes are duplicated.
398 assert_eq!(
399 resume_offset(5_000, 200),
400 0,
401 "a 200 carries the whole stream; appending it corrupts the file"
402 );
403 }
404
405 /// The regression: `with_extension` replaced the extension, so the worker
406 /// wrote `movie.part` while every cleanup path deleted `movie.mp4.part`.
407 /// Nothing matched, and partial files accumulated forever.
408 ///
409 /// TRACES: UR-055 | DR-169 | UT-163
410 #[test]
411 fn test_partial_path_appends_rather_than_replacing_the_extension() {
412 use std::path::Path;
413
414 assert_eq!(
415 partial_path(Path::new("/media/movie.mp4")),
416 Path::new("/media/movie.mp4.part"),
417 "the cleanup paths delete \"{{file_path}}.part\"; this must produce it"
418 );
419
420 // Two sources for one title must not fight over a single partial file.
421 assert_ne!(
422 partial_path(Path::new("/media/movie.mp4")),
423 partial_path(Path::new("/media/movie.mkv")),
424 );
425
426 // Extension-less targets still get a sidecar rather than being clobbered.
427 assert_eq!(
428 partial_path(Path::new("/media/track")),
429 Path::new("/media/track.part"),
430 );
431
432 // A dotted name keeps every part of its own name.
433 assert_eq!(
434 partial_path(Path::new("/media/S01.E02.episode.mkv")),
435 Path::new("/media/S01.E02.episode.mkv.part"),
436 );
437 }
438
439 #[test]
440 fn test_exponential_backoff() {
441 assert_eq!(
442 DownloadWorker::exponential_backoff(1),
443 Duration::from_secs(5)
444 );
445 assert_eq!(
446 DownloadWorker::exponential_backoff(2),
447 Duration::from_secs(15)
448 );
449 assert_eq!(
450 DownloadWorker::exponential_backoff(3),
451 Duration::from_secs(45)
452 );
453 }
454
455 #[test]
456 fn test_error_retryable() {
457 assert!(DownloadError::Network("timeout".to_string()).is_retryable());
458 assert!(DownloadError::Http(500).is_retryable());
459 assert!(DownloadError::Http(503).is_retryable());
460 assert!(!DownloadError::Http(404).is_retryable());
461 assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
462 // Retrying a paused download would restart what the user just stopped.
463 assert!(!DownloadError::Stopped.is_retryable());
464 assert!(DownloadError::Stopped.is_stopped());
465 assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
466 }
467}
468
469/// Transfers that outlive the timeout. These drive the real `reqwest` client
470/// against a loopback socket because the defect lived in how that client was
471/// configured, not in any code of ours a mock could stand in for.
472#[cfg(test)]
473mod timeout_tests {
474 use super::*;
475 use tokio::net::TcpListener;
476
477 /// Serve one HTTP/1.1 response of `chunks` bodies of `chunk_len` bytes,
478 /// pausing `gap` between them. `hang_after` chunks, the server stops sending
479 /// and never closes — a stalled connection.
480 async fn dribbling_server(
481 chunks: usize,
482 chunk_len: usize,
483 gap: Duration,
484 hang_after: Option<usize>,
485 ) -> String {
486 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
487 let addr = listener.local_addr().unwrap();
488 tokio::spawn(async move {
489 let (mut sock, _) = listener.accept().await.unwrap();
490 // Drain the request head; we answer the same thing regardless.
491 let mut buf = [0u8; 4096];
492 let _ = tokio::io::AsyncReadExt::read(&mut sock, &mut buf).await;
493 let head = format!(
494 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
495 chunks * chunk_len
496 );
497 if sock.write_all(head.as_bytes()).await.is_err() {
498 return;
499 }
500 let body = vec![b'x'; chunk_len];
501 for i in 0..chunks {
502 if hang_after == Some(i) {
503 // Hold the socket open forever without writing.
504 tokio::time::sleep(Duration::from_secs(3600)).await;
505 }
506 // The client hanging up (as it does once it times out) is not
507 // the server's failure to report.
508 if sock.write_all(&body).await.is_err() || sock.flush().await.is_err() {
509 return;
510 }
511 tokio::time::sleep(gap).await;
512 }
513 });
514 format!("http://{}/file.bin", addr)
515 }
516
517 /// A download that takes longer than the timeout but never stalls must
518 /// finish. The worker set `Client::timeout`, which in reqwest is a *total*
519 /// deadline covering the body, so every transfer longer than five minutes —
520 /// any film at transcode speed — was cut off with "error decoding response
521 /// body", retried from byte zero (a transcode ignores `Range`), and cut off
522 /// again until the retry budget ran out.
523 ///
524 /// TRACES: UR-071 | DR-289 | UT-251
525 #[tokio::test]
526 async fn test_download_longer_than_the_stall_timeout_completes_when_bytes_keep_flowing() {
527 let stall = Duration::from_millis(400);
528 // 12 chunks × 100 ms ≈ 1.2 s of transfer, three times the stall timeout,
529 // with every gap comfortably inside it.
530 let url = dribbling_server(12, 1024, Duration::from_millis(100), None).await;
531 let dir = tempfile::tempdir().unwrap();
532 let task = DownloadTask {
533 url,
534 target_path: dir.path().join("file.bin"),
535 };
536
537 let worker = DownloadWorker::with_stall_timeout(stall, false);
538 let result = worker
539 .download(&task, &AtomicBool::new(false), |_, _| {})
540 .await;
541
542 let result = result
543 .unwrap_or_else(|e| panic!("a transfer that never stalls must not time out: {e:?}"));
544 assert_eq!(result.bytes_downloaded, 12 * 1024);
545 assert!(task.target_path.exists());
546 }
547
548 /// The converse: a connection that goes silent is still given up on, so
549 /// dropping the total deadline did not turn a dead wifi link into a download
550 /// that hangs forever with no retry.
551 ///
552 /// TRACES: UR-071 | DR-289 | UT-251
553 #[tokio::test]
554 async fn test_download_that_stalls_is_given_up_on() {
555 let stall = Duration::from_millis(300);
556 let url = dribbling_server(4, 1024, Duration::from_millis(10), Some(2)).await;
557 let dir = tempfile::tempdir().unwrap();
558 let task = DownloadTask {
559 url,
560 target_path: dir.path().join("file.bin"),
561 };
562
563 let worker = DownloadWorker::with_stall_timeout(stall, false);
564 // `download()` retries with 5 s/15 s/45 s backoff; a single attempt is
565 // what proves the stall is detected.
566 let started = std::time::Instant::now();
567 let result = worker
568 .try_download(&task, &AtomicBool::new(false), &|_, _| {})
569 .await;
570
571 assert!(
572 matches!(result, Err(DownloadError::Network(_))),
573 "a stalled transfer must fail as a network error: {result:?}"
574 );
575 assert!(
576 started.elapsed() < Duration::from_secs(5),
577 "the stall must be detected promptly, took {:?}",
578 started.elapsed()
579 );
580 }
581}