msansm1/rss-podcast-dlpublic Fork
⑂ 812d86c
Commits
⬇ Clone ▾
git clone https://git.rickub.com/msansm1/rss-podcast-dl.git
git clone ssh://git@rickub.com/msansm1/rss-podcast-dl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Téléchargement atomique : écriture via fichier .part puis renommage

- download_file écrit dans <fichier>.part et ne renomme vers la
  destination finale qu'une fois le téléchargement complet et la
  taille vérifiée : un téléchargement interrompu n'est plus pris
  pour un épisode complet et est retéléchargé à la sync suivante
- le .part est supprimé en cas d'erreur (réseau, taille, écriture)
- suppression de la requête HEAD préalable : le Content-Length vient
  du GET, et un serveur sans Content-Length ne fait plus échouer
  le téléchargement (progression à 0 dans ce cas)
- un mismatch de taille échoue désormais avec ContentLengthMismatch
  au lieu d'être seulement loggé
- tests : .part orphelin ignoré par needs_download, construction
  du chemin .part
msansm1 committed 2026-09-28T22:18:59+02:00 Browse files
812d86c parent: 1c88366
modified src/rss/downloader.rs +100 -44
@@ -91,28 +91,9 @@ pub fn download_file(
9191 .build()
9292 .map_err(|e| DownloadError::NetworkError(e.to_string()))?;
9393
94- // Send HEAD request first to get content length
95- let head_response = match client.head(url).send() {
96- Ok(resp) => resp,
97- Err(e) => {
98- if e.is_timeout() {
99- return Err(DownloadError::Timeout(60));
100- }
101- return Err(DownloadError::NetworkError(e.to_string()));
102- }
103- };
104-
105- if !head_response.status().is_success() {
106- return Err(DownloadError::HttpError(format!("HTTP {}: {}", head_response.status(), head_response.status())));
107- }
108-
109- let total_size = head_response
110- .content_length()
111- .ok_or(DownloadError::ContentLengthMismatch)?;
112-
11394 info!("Downloading {} to {}", url, dest.display());
11495
115- // Download the file
96+ // Send the GET request; the content length comes from this response
11697 let response = client.get(url).send()
11798 .map_err(|e| {
11899 if e.is_timeout() {
@@ -123,37 +104,83 @@ pub fn download_file(
123104 })?;
124105
125106 if !response.status().is_success() {
126- return Err(DownloadError::HttpError(format!("HTTP {}: {}", response.status(), response.status())));
107+ return Err(DownloadError::HttpError(format!("HTTP {}", response.status())));
127108 }
128109
129- // Create the file
130- let mut file = fs::File::create(dest)
110+ let total_size = response.content_length();
111+
112+ // Download to a temporary .part file. The file is only renamed to its
113+ // final destination once complete, so an interrupted download is
114+ // detected and retried on the next sync.
115+ let part_path = part_file_path(dest);
116+
117+ let downloaded = match stream_to_file(response, &part_path, total_size, &progress_callback) {
118+ Ok(n) => n,
119+ Err(e) => {
120+ // Clean up the partial file so it is never mistaken for a
121+ // complete download
122+ let _ = fs::remove_file(&part_path);
123+ return Err(e);
124+ }
125+ };
126+
127+ // Rename the completed part file to its final destination.
128+ // On Windows, rename fails if the destination exists: remove it first
129+ // (e.g. a race with a previous incomplete attempt).
130+ if dest.exists() {
131+ fs::remove_file(dest)
132+ .map_err(|e| DownloadError::WriteError(e.to_string()))?;
133+ }
134+ fs::rename(&part_path, dest)
135+ .map_err(|e| DownloadError::WriteError(e.to_string()))?;
136+
137+ info!("Successfully downloaded {} bytes to {}", downloaded, dest.display());
138+
139+ Ok(())
140+}
141+
142+/// Returns the temporary ".part" path for a destination file
143+fn part_file_path(dest: &Path) -> PathBuf {
144+ let mut name = dest.file_name().map(|n| n.to_os_string()).unwrap_or_default();
145+ name.push(".part");
146+ dest.with_file_name(name)
147+}
148+
149+/// Streams a response body to a file, reporting download progress.
150+/// Fails if the downloaded size does not match the expected content length.
151+fn stream_to_file(
152+ mut response: reqwest::blocking::Response,
153+ part_path: &Path,
154+ total_size: Option<u64>,
155+ progress_callback: &Option<ProgressCallback>,
156+) -> Result<u64, DownloadError> {
157+ let mut file = fs::File::create(part_path)
131158 .map_err(|e| DownloadError::FileCreationError(e.to_string()))?;
132159
133160 let mut downloaded: u64 = 0;
134161
135162 // Read response in chunks
136- let mut reader = response;
137163 let mut buffer = vec![0u8; 8192];
138164
139165 loop {
140- let bytes_read = match reader.read(&mut buffer) {
166+ let bytes_read = match response.read(&mut buffer) {
141167 Ok(0) => break, // EOF
142168 Ok(n) => n,
143169 Err(e) => return Err(DownloadError::NetworkError(e.to_string())),
144170 };
145-
171+
146172 file.write_all(&buffer[..bytes_read])
147173 .map_err(|e| DownloadError::WriteError(e.to_string()))?;
148-
174+
149175 downloaded += bytes_read as u64;
150-
176+
151177 // Report progress if callback is provided
152- if let Some(cb) = &progress_callback {
153- let progress = if total_size > 0 {
154- (downloaded as f32 / total_size as f32).min(1.0)
155- } else {
156- 0.0
178+ if let Some(cb) = progress_callback {
179+ let progress = match total_size {
180+ Some(total) if total > 0 => {
181+ (downloaded as f32 / total as f32).min(1.0)
182+ }
183+ _ => 0.0,
157184 };
158185 cb(progress);
159186 }
@@ -161,15 +188,18 @@ pub fn download_file(
161188
162189 file.flush().map_err(|e| DownloadError::WriteError(e.to_string()))?;
163190
164- // Verify size matches
165- if total_size > 0 && downloaded != total_size {
166- warn!("Downloaded size mismatch: expected {}, got {}", total_size, downloaded);
167- // Don't fail, but log the discrepancy
191+ // Verify size matches; an incomplete file must not be kept
192+ if let Some(total) = total_size {
193+ if downloaded != total {
194+ warn!(
195+ "Downloaded size mismatch: expected {}, got {}",
196+ total, downloaded
197+ );
198+ return Err(DownloadError::ContentLengthMismatch);
199+ }
168200 }
169201
170- info!("Successfully downloaded {} bytes to {}", downloaded, dest.display());
171-
172- Ok(())
202+ Ok(downloaded)
173203 }
174204
175205 /// Downloads an episode to its designated location
@@ -220,13 +250,13 @@ pub fn download_episodes(
220250 download_path: &Path,
221251 ) -> Result<Vec<PathBuf>, DownloadError> {
222252 let mut downloaded_paths = Vec::new();
223-
253+
224254 for episode in episodes {
225255 // Use a no-op callback for now
226256 let path = download_episode(episode, download_path, Box::new(|_| {}))?;
227257 downloaded_paths.push(path);
228258 }
229-
259+
230260 Ok(downloaded_paths)
231261 }
232262
@@ -253,13 +283,13 @@ pub fn needs_download(episode: &Episode, download_path: &Path) -> bool {
253283 #[allow(dead_code)]
254284 pub fn remove_downloaded_episode(episode: &Episode, download_path: &Path) -> Result<(), DownloadError> {
255285 let save_path = episode.get_save_path(download_path);
256-
286+
257287 if save_path.exists() {
258288 fs::remove_file(&save_path)
259289 .map_err(|e| DownloadError::WriteError(e.to_string()))?;
260290 info!("Removed downloaded episode: {}", episode.title);
261291 }
262-
292+
263293 Ok(())
264294 }
265295
@@ -305,6 +335,32 @@ mod tests {
305335 assert!(!needs_download(&episode, download_path));
306336 }
307337
338+ #[test]
339+ fn test_needs_download_part_file_only() {
340+ let episode = Episode::new(
341+ "ep-123",
342+ "Test Episode",
343+ "https://example.com/episode.mp3",
344+ Utc::now(),
345+ );
346+ let temp_dir = tempdir().unwrap();
347+ let download_path = temp_dir.path();
348+
349+ // Only an interrupted (.part) download exists: it must be re-downloaded
350+ let part_path = part_file_path(&download_path.join(&episode.audio_filename));
351+ fs::write(&part_path, "partial content").unwrap();
352+
353+ assert!(needs_download(&episode, download_path));
354+ }
355+
356+ #[test]
357+ fn test_part_file_path() {
358+ let dest = Path::new("downloads").join("episode.mp3");
359+ let part = part_file_path(&dest);
360+ assert_eq!(part, Path::new("downloads").join("episode.mp3.part"));
361+ assert_eq!(part.file_name().unwrap().to_string_lossy(), "episode.mp3.part");
362+ }
363+
308364 #[test]
309365 fn test_get_download_dir() {
310366 let path = PathBuf::from("/downloads/podcasts");
@@ -91,28 +91,9 @@ pub fn download_file(
91 .build()91 .build()
92 .map_err(|e| DownloadError::NetworkError(e.to_string()))?;92 .map_err(|e| DownloadError::NetworkError(e.to_string()))?;
93 93
94- // Send HEAD request first to get content length
95- let head_response = match client.head(url).send() {
96- Ok(resp) => resp,
97- Err(e) => {
98- if e.is_timeout() {
99- return Err(DownloadError::Timeout(60));
100- }
101- return Err(DownloadError::NetworkError(e.to_string()));
102- }
103- };
104-
105- if !head_response.status().is_success() {
106- return Err(DownloadError::HttpError(format!("HTTP {}: {}", head_response.status(), head_response.status())));
107- }
108-
109- let total_size = head_response
110- .content_length()
111- .ok_or(DownloadError::ContentLengthMismatch)?;
112-
113 info!("Downloading {} to {}", url, dest.display());94 info!("Downloading {} to {}", url, dest.display());
114 95
115- // Download the file96+ // Send the GET request; the content length comes from this response
116 let response = client.get(url).send()97 let response = client.get(url).send()
117 .map_err(|e| {98 .map_err(|e| {
118 if e.is_timeout() {99 if e.is_timeout() {
@@ -123,37 +104,83 @@ pub fn download_file(
123 })?;104 })?;
124 105
125 if !response.status().is_success() {106 if !response.status().is_success() {
126- return Err(DownloadError::HttpError(format!("HTTP {}: {}", response.status(), response.status())));107+ return Err(DownloadError::HttpError(format!("HTTP {}", response.status())));
127 }108 }
128 109
129- // Create the file110+ let total_size = response.content_length();
130- let mut file = fs::File::create(dest)111+
112+ // Download to a temporary .part file. The file is only renamed to its
113+ // final destination once complete, so an interrupted download is
114+ // detected and retried on the next sync.
115+ let part_path = part_file_path(dest);
116+
117+ let downloaded = match stream_to_file(response, &part_path, total_size, &progress_callback) {
118+ Ok(n) => n,
119+ Err(e) => {
120+ // Clean up the partial file so it is never mistaken for a
121+ // complete download
122+ let _ = fs::remove_file(&part_path);
123+ return Err(e);
124+ }
125+ };
126+
127+ // Rename the completed part file to its final destination.
128+ // On Windows, rename fails if the destination exists: remove it first
129+ // (e.g. a race with a previous incomplete attempt).
130+ if dest.exists() {
131+ fs::remove_file(dest)
132+ .map_err(|e| DownloadError::WriteError(e.to_string()))?;
133+ }
134+ fs::rename(&part_path, dest)
135+ .map_err(|e| DownloadError::WriteError(e.to_string()))?;
136+
137+ info!("Successfully downloaded {} bytes to {}", downloaded, dest.display());
138+
139+ Ok(())
140+}
141+
142+/// Returns the temporary ".part" path for a destination file
143+fn part_file_path(dest: &Path) -> PathBuf {
144+ let mut name = dest.file_name().map(|n| n.to_os_string()).unwrap_or_default();
145+ name.push(".part");
146+ dest.with_file_name(name)
147+}
148+
149+/// Streams a response body to a file, reporting download progress.
150+/// Fails if the downloaded size does not match the expected content length.
151+fn stream_to_file(
152+ mut response: reqwest::blocking::Response,
153+ part_path: &Path,
154+ total_size: Option<u64>,
155+ progress_callback: &Option<ProgressCallback>,
156+) -> Result<u64, DownloadError> {
157+ let mut file = fs::File::create(part_path)
131 .map_err(|e| DownloadError::FileCreationError(e.to_string()))?;158 .map_err(|e| DownloadError::FileCreationError(e.to_string()))?;
132 159
133 let mut downloaded: u64 = 0;160 let mut downloaded: u64 = 0;
134 161
135 // Read response in chunks162 // Read response in chunks
136- let mut reader = response;
137 let mut buffer = vec![0u8; 8192];163 let mut buffer = vec![0u8; 8192];
138 164
139 loop {165 loop {
140- let bytes_read = match reader.read(&mut buffer) {166+ let bytes_read = match response.read(&mut buffer) {
141 Ok(0) => break, // EOF167 Ok(0) => break, // EOF
142 Ok(n) => n,168 Ok(n) => n,
143 Err(e) => return Err(DownloadError::NetworkError(e.to_string())),169 Err(e) => return Err(DownloadError::NetworkError(e.to_string())),
144 };170 };
145- 171+
146 file.write_all(&buffer[..bytes_read])172 file.write_all(&buffer[..bytes_read])
147 .map_err(|e| DownloadError::WriteError(e.to_string()))?;173 .map_err(|e| DownloadError::WriteError(e.to_string()))?;
148- 174+
149 downloaded += bytes_read as u64;175 downloaded += bytes_read as u64;
150- 176+
151 // Report progress if callback is provided177 // Report progress if callback is provided
152- if let Some(cb) = &progress_callback {178+ if let Some(cb) = progress_callback {
153- let progress = if total_size > 0 {179+ let progress = match total_size {
154- (downloaded as f32 / total_size as f32).min(1.0)180+ Some(total) if total > 0 => {
155- } else {181+ (downloaded as f32 / total as f32).min(1.0)
156- 0.0182+ }
183+ _ => 0.0,
157 };184 };
158 cb(progress);185 cb(progress);
159 }186 }
@@ -161,15 +188,18 @@ pub fn download_file(
161 188
162 file.flush().map_err(|e| DownloadError::WriteError(e.to_string()))?;189 file.flush().map_err(|e| DownloadError::WriteError(e.to_string()))?;
163 190
164- // Verify size matches191+ // Verify size matches; an incomplete file must not be kept
165- if total_size > 0 && downloaded != total_size {192+ if let Some(total) = total_size {
166- warn!("Downloaded size mismatch: expected {}, got {}", total_size, downloaded);193+ if downloaded != total {
167- // Don't fail, but log the discrepancy194+ warn!(
195+ "Downloaded size mismatch: expected {}, got {}",
196+ total, downloaded
197+ );
198+ return Err(DownloadError::ContentLengthMismatch);
199+ }
168 }200 }
169 201
170- info!("Successfully downloaded {} bytes to {}", downloaded, dest.display());202+ Ok(downloaded)
171-
172- Ok(())
173 }203 }
174 204
175 /// Downloads an episode to its designated location205 /// Downloads an episode to its designated location
@@ -220,13 +250,13 @@ pub fn download_episodes(
220 download_path: &Path,250 download_path: &Path,
221 ) -> Result<Vec<PathBuf>, DownloadError> {251 ) -> Result<Vec<PathBuf>, DownloadError> {
222 let mut downloaded_paths = Vec::new();252 let mut downloaded_paths = Vec::new();
223- 253+
224 for episode in episodes {254 for episode in episodes {
225 // Use a no-op callback for now255 // Use a no-op callback for now
226 let path = download_episode(episode, download_path, Box::new(|_| {}))?;256 let path = download_episode(episode, download_path, Box::new(|_| {}))?;
227 downloaded_paths.push(path);257 downloaded_paths.push(path);
228 }258 }
229- 259+
230 Ok(downloaded_paths)260 Ok(downloaded_paths)
231 }261 }
232 262
@@ -253,13 +283,13 @@ pub fn needs_download(episode: &Episode, download_path: &Path) -> bool {
253 #[allow(dead_code)]283 #[allow(dead_code)]
254 pub fn remove_downloaded_episode(episode: &Episode, download_path: &Path) -> Result<(), DownloadError> {284 pub fn remove_downloaded_episode(episode: &Episode, download_path: &Path) -> Result<(), DownloadError> {
255 let save_path = episode.get_save_path(download_path);285 let save_path = episode.get_save_path(download_path);
256- 286+
257 if save_path.exists() {287 if save_path.exists() {
258 fs::remove_file(&save_path)288 fs::remove_file(&save_path)
259 .map_err(|e| DownloadError::WriteError(e.to_string()))?;289 .map_err(|e| DownloadError::WriteError(e.to_string()))?;
260 info!("Removed downloaded episode: {}", episode.title);290 info!("Removed downloaded episode: {}", episode.title);
261 }291 }
262- 292+
263 Ok(())293 Ok(())
264 }294 }
265 295
@@ -305,6 +335,32 @@ mod tests {
305 assert!(!needs_download(&episode, download_path));335 assert!(!needs_download(&episode, download_path));
306 }336 }
307 337
338+ #[test]
339+ fn test_needs_download_part_file_only() {
340+ let episode = Episode::new(
341+ "ep-123",
342+ "Test Episode",
343+ "https://example.com/episode.mp3",
344+ Utc::now(),
345+ );
346+ let temp_dir = tempdir().unwrap();
347+ let download_path = temp_dir.path();
348+
349+ // Only an interrupted (.part) download exists: it must be re-downloaded
350+ let part_path = part_file_path(&download_path.join(&episode.audio_filename));
351+ fs::write(&part_path, "partial content").unwrap();
352+
353+ assert!(needs_download(&episode, download_path));
354+ }
355+
356+ #[test]
357+ fn test_part_file_path() {
358+ let dest = Path::new("downloads").join("episode.mp3");
359+ let part = part_file_path(&dest);
360+ assert_eq!(part, Path::new("downloads").join("episode.mp3.part"));
361+ assert_eq!(part.file_name().unwrap().to_string_lossy(), "episode.mp3.part");
362+ }
363+
308 #[test]364 #[test]
309 fn test_get_download_dir() {365 fn test_get_download_dir() {
310 let path = PathBuf::from("/downloads/podcasts");366 let path = PathBuf::from("/downloads/podcasts");