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
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        // Move from .part to final location
187        fs::rename(&temp_path, &task.target_path)
188            .await
189            .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
190
191        Ok(DownloadResult {
192            bytes_downloaded: downloaded,
193        })
194    }
195
196    /// Calculate exponential backoff delay
197    fn exponential_backoff(retry_count: u32) -> Duration {
198        let base_delay = 5; // 5 seconds
199        let delay_secs = base_delay * 3u64.pow(retry_count - 1); // 5s, 15s, 45s
200        Duration::from_secs(delay_secs)
201    }
202}
203
204/// Where to resume writing a partial download, given how the server answered.
205///
206/// A byte offset of 0 means "start the file again"; anything else means "append
207/// from here".
208///
209/// This is what makes non-`original` downloads survive. Those presets ask
210/// Jellyfin to **transcode**, and a live transcode is chunked with no
211/// `Content-Length` and cannot be byte-seeked: the server ignores `Range` and
212/// answers `200` with the whole stream from the beginning, not `206` with the
213/// requested tail. The worker sent the header and appended the body regardless,
214/// so every retry — and every resume — concatenated a fresh copy of the whole
215/// transcode onto the bytes already on disk. The file grew past its real size
216/// and would not play. Only a `206` actually promises the tail; a `200` means we
217/// must discard what we have and take the stream from the top.
218///
219/// TRACES: UR-071 | DR-170
220pub fn resume_offset(existing_bytes: u64, status: u16) -> u64 {
221    if existing_bytes == 0 {
222        return 0;
223    }
224    // 206 Partial Content is the only answer that honours the Range request.
225    if status == 206 {
226        existing_bytes
227    } else {
228        0
229    }
230}
231
232/// The partial-download sidecar for `target`.
233///
234/// **Appends** `.part` rather than replacing the extension. The worker used
235/// `Path::with_extension("part")`, which replaces: `movie.mp4` became
236/// `movie.part`. Every cleanup path meanwhile deleted `"{file_path}.part"` —
237/// `movie.mp4.part` — so nothing ever matched and the partial file of every
238/// cancelled or failed download was left on disk forever, invisible to the
239/// disk-usage totals because no `downloads` row pointed at it. That is the
240/// reported "failure is not cleaned".
241///
242/// Appending also removes a collision the old form had: `movie.mp4` and
243/// `movie.mkv` both mapped to `movie.part` and would have fought over one file.
244///
245/// One function so the writer and the cleaners cannot disagree again.
246///
247/// TRACES: UR-055 | DR-169
248pub fn partial_path(target: &std::path::Path) -> std::path::PathBuf {
249    let mut s = target.as_os_str().to_os_string();
250    s.push(".part");
251    std::path::PathBuf::from(s)
252}
253
254/// Result of a successful download
255#[derive(Debug)]
256pub struct DownloadResult {
257    pub bytes_downloaded: u64,
258}
259
260/// Download error types
261#[derive(Debug)]
262pub enum DownloadError {
263    Network(String),
264    Http(u16),
265    FileSystem(String),
266    /// The download was asked to stop (paused or cancelled). Not a failure: the
267    /// row's status already says what happened, and the partial file is kept so a
268    /// resume can continue from it.
269    Stopped,
270}
271
272impl DownloadError {
273    /// Check if this error is retryable
274    fn is_retryable(&self) -> bool {
275        match self {
276            DownloadError::Network(_) => true,
277            DownloadError::Http(status) => *status >= 500, // Retry server errors
278            DownloadError::FileSystem(_) => false,
279            // Retrying would restart the very download the user just paused.
280            DownloadError::Stopped => false,
281        }
282    }
283
284    /// Whether this outcome means "the user stopped it", rather than a failure to
285    /// record and report.
286    pub fn is_stopped(&self) -> bool {
287        matches!(self, DownloadError::Stopped)
288    }
289}
290
291impl std::fmt::Display for DownloadError {
292    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293        match self {
294            DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
295            DownloadError::Http(status) => write!(f, "HTTP error {}", status),
296            DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
297            DownloadError::Stopped => write!(f, "Download stopped by request"),
298        }
299    }
300}
301
302impl std::error::Error for DownloadError {}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    /// The bitrate-download corruption: a transcode ignores `Range` and answers
309    /// `200` with the whole stream. Appending that to the bytes already on disk
310    /// duplicated them, so every retry grew the file past its real size and left
311    /// it unplayable. Only `206` promises the requested tail.
312    ///
313    /// TRACES: UR-071 | DR-170 | UT-164
314    #[test]
315    fn test_resume_offset_only_appends_when_the_server_honoured_the_range() {
316        // Nothing on disk: start at the beginning either way.
317        assert_eq!(resume_offset(0, 200), 0);
318        assert_eq!(resume_offset(0, 206), 0);
319
320        // The server agreed to the range — append to what we have.
321        assert_eq!(resume_offset(5_000, 206), 5_000);
322
323        // The server ignored it and is sending the whole file (a transcode).
324        // Restart, or the bytes are duplicated.
325        assert_eq!(
326            resume_offset(5_000, 200),
327            0,
328            "a 200 carries the whole stream; appending it corrupts the file"
329        );
330    }
331
332    /// The regression: `with_extension` replaced the extension, so the worker
333    /// wrote `movie.part` while every cleanup path deleted `movie.mp4.part`.
334    /// Nothing matched, and partial files accumulated forever.
335    ///
336    /// TRACES: UR-055 | DR-169 | UT-163
337    #[test]
338    fn test_partial_path_appends_rather_than_replacing_the_extension() {
339        use std::path::Path;
340
341        assert_eq!(
342            partial_path(Path::new("/media/movie.mp4")),
343            Path::new("/media/movie.mp4.part"),
344            "the cleanup paths delete \"{{file_path}}.part\"; this must produce it"
345        );
346
347        // Two sources for one title must not fight over a single partial file.
348        assert_ne!(
349            partial_path(Path::new("/media/movie.mp4")),
350            partial_path(Path::new("/media/movie.mkv")),
351        );
352
353        // Extension-less targets still get a sidecar rather than being clobbered.
354        assert_eq!(
355            partial_path(Path::new("/media/track")),
356            Path::new("/media/track.part"),
357        );
358
359        // A dotted name keeps every part of its own name.
360        assert_eq!(
361            partial_path(Path::new("/media/S01.E02.episode.mkv")),
362            Path::new("/media/S01.E02.episode.mkv.part"),
363        );
364    }
365
366    #[test]
367    fn test_exponential_backoff() {
368        assert_eq!(
369            DownloadWorker::exponential_backoff(1),
370            Duration::from_secs(5)
371        );
372        assert_eq!(
373            DownloadWorker::exponential_backoff(2),
374            Duration::from_secs(15)
375        );
376        assert_eq!(
377            DownloadWorker::exponential_backoff(3),
378            Duration::from_secs(45)
379        );
380    }
381
382    #[test]
383    fn test_error_retryable() {
384        assert!(DownloadError::Network("timeout".to_string()).is_retryable());
385        assert!(DownloadError::Http(500).is_retryable());
386        assert!(DownloadError::Http(503).is_retryable());
387        assert!(!DownloadError::Http(404).is_retryable());
388        assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
389        // Retrying a paused download would restart what the user just stopped.
390        assert!(!DownloadError::Stopped.is_retryable());
391        assert!(DownloadError::Stopped.is_stopped());
392        assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
393    }
394}