Skip to main content

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}