1use anyhow::{Context, Result};
2use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
3use std::collections::VecDeque;
4use std::path::PathBuf;
5use std::sync::Arc;
6use tokio::fs;
7use tokio::sync::Mutex;
8
9use crate::api::SmugMugClient;
10use crate::api::images::AlbumImage;
11
12pub struct DownloadOptions {
13 pub album_key: String,
14 pub output_dir: PathBuf,
15 pub client: Arc<SmugMugClient>,
16 pub threads: usize,
17}
18
19pub struct DownloadStats {
20 pub total_images: usize,
21 pub downloaded: usize,
22 pub failed: usize,
23 pub total_bytes: u64,
24}
25
26pub async fn download_album(options: DownloadOptions) -> Result<DownloadStats> {
27 let images = options
29 .client
30 .list_album_images(&options.album_key)
31 .await
32 .context("Failed to list album images")?;
33
34 if images.is_empty() {
35 println!("No images found in album");
36 return Ok(DownloadStats {
37 total_images: 0,
38 downloaded: 0,
39 failed: 0,
40 total_bytes: 0,
41 });
42 }
43
44 println!("Found {} images to download", images.len());
45
46 fs::create_dir_all(&options.output_dir)
48 .await
49 .context("Failed to create output directory")?;
50
51 let mut queue = VecDeque::new();
53 for image in &images {
54 queue.push_back(image.clone());
55 }
56
57 let multi_progress = MultiProgress::new();
59 let overall_progress = multi_progress.add(ProgressBar::new(images.len() as u64));
60 overall_progress.set_style(
61 ProgressStyle::default_bar()
62 .template(
63 "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})",
64 )
65 .unwrap()
66 .progress_chars("#>-"),
67 );
68
69 let stats = Arc::new(Mutex::new(DownloadStats {
71 total_images: images.len(),
72 downloaded: 0,
73 failed: 0,
74 total_bytes: 0,
75 }));
76
77 let queue = Arc::new(Mutex::new(queue));
79 let output_dir = Arc::new(options.output_dir);
80 let mut handles = vec![];
81
82 for _ in 0..options.threads {
83 let queue = queue.clone();
84 let stats = stats.clone();
85 let progress = overall_progress.clone();
86 let output_dir = output_dir.clone();
87
88 let handle = tokio::spawn(async move {
89 let http_client = reqwest::Client::new();
90
91 loop {
92 let image = {
94 let mut q = queue.lock().await;
95 q.pop_front()
96 };
97
98 let Some(image) = image else {
99 break;
100 };
101
102 match download_image(&http_client, &image, &output_dir).await {
104 Ok(bytes_downloaded) => {
105 let mut stats = stats.lock().await;
106 stats.downloaded += 1;
107 stats.total_bytes += bytes_downloaded;
108 progress.set_message(format!("Downloaded: {}", image.file_name));
109 progress.inc(1);
110 }
111 Err(e) => {
112 let mut stats = stats.lock().await;
113 stats.failed += 1;
114 progress.set_message(format!("Failed: {} - {}", image.file_name, e));
115 progress.inc(1);
116 }
117 }
118 }
119 });
120
121 handles.push(handle);
122 }
123
124 for handle in handles {
126 handle.await?;
127 }
128
129 overall_progress.finish_with_message("Download complete");
130
131 let final_stats = stats.lock().await;
133 Ok(DownloadStats {
134 total_images: final_stats.total_images,
135 downloaded: final_stats.downloaded,
136 failed: final_stats.failed,
137 total_bytes: final_stats.total_bytes,
138 })
139}
140
141async fn download_image(
142 client: &reqwest::Client,
143 image: &AlbumImage,
144 output_dir: &PathBuf,
145) -> Result<u64> {
146 let response = client
148 .get(&image.archived_uri)
149 .send()
150 .await
151 .context("Failed to download image")?;
152
153 if !response.status().is_success() {
154 anyhow::bail!("Download failed with status: {}", response.status());
155 }
156
157 let bytes = response
158 .bytes()
159 .await
160 .context("Failed to read response bytes")?;
161
162 let bytes_len = bytes.len() as u64;
163
164 let mut output_path = output_dir.join(&image.file_name);
166 let mut counter = 1;
167
168 while output_path.exists() {
169 let extension = output_path
171 .extension()
172 .and_then(|e| e.to_str())
173 .unwrap_or("");
174 let stem = output_path
175 .file_stem()
176 .and_then(|s| s.to_str())
177 .unwrap_or("file");
178
179 let new_filename = if extension.is_empty() {
181 format!("{}_{}", stem, counter)
182 } else {
183 format!("{}_{}.{}", stem, counter, extension)
184 };
185
186 output_path = output_dir.join(new_filename);
187 counter += 1;
188 }
189
190 fs::write(&output_path, bytes)
192 .await
193 .context("Failed to write image file")?;
194
195 Ok(bytes_len)
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use crate::api::images::AlbumImage;
202 use mockito::Server;
203 use tempfile::TempDir;
204 use tokio::fs;
205
206 fn create_test_image(file_name: &str, image_key: &str) -> AlbumImage {
208 AlbumImage {
209 image_key: image_key.to_string(),
210 file_name: file_name.to_string(),
211 archived_uri: format!("https://example.com/{}", file_name),
212 file_size: 1024,
213 format: "JPG".to_string(),
214 uri: format!("https://example.com/api/image/{}", image_key),
215 title: Some("Test Image".to_string()),
216 archived_md5: Some("abc123".to_string()),
217 }
218 }
219
220 fn create_test_image_with_url(file_name: &str, image_key: &str, url: &str) -> AlbumImage {
222 AlbumImage {
223 image_key: image_key.to_string(),
224 file_name: file_name.to_string(),
225 archived_uri: url.to_string(),
226 file_size: 1024,
227 format: "JPG".to_string(),
228 uri: format!("https://example.com/api/image/{}", image_key),
229 title: Some("Test Image".to_string()),
230 archived_md5: Some("abc123".to_string()),
231 }
232 }
233
234 #[test]
235 fn test_download_options_creation() {
236 let client = Arc::new(SmugMugClient::new(
237 "test_key".to_string(),
238 "test_secret".to_string(),
239 "test_token".to_string(),
240 "test_token_secret".to_string(),
241 ));
242
243 let options = DownloadOptions {
244 album_key: "test_album".to_string(),
245 output_dir: PathBuf::from("/tmp/test"),
246 client: client.clone(),
247 threads: 4,
248 };
249
250 assert_eq!(options.album_key, "test_album");
251 assert_eq!(options.output_dir, PathBuf::from("/tmp/test"));
252 assert_eq!(options.threads, 4);
253 }
254
255 #[test]
256 fn test_download_stats_initialization() {
257 let stats = DownloadStats {
258 total_images: 10,
259 downloaded: 5,
260 failed: 2,
261 total_bytes: 1024000,
262 };
263
264 assert_eq!(stats.total_images, 10);
265 assert_eq!(stats.downloaded, 5);
266 assert_eq!(stats.failed, 2);
267 assert_eq!(stats.total_bytes, 1024000);
268 }
269
270 #[test]
271 fn test_download_stats_empty() {
272 let stats = DownloadStats {
273 total_images: 0,
274 downloaded: 0,
275 failed: 0,
276 total_bytes: 0,
277 };
278
279 assert_eq!(stats.total_images, 0);
280 assert_eq!(stats.downloaded, 0);
281 assert_eq!(stats.failed, 0);
282 assert_eq!(stats.total_bytes, 0);
283 }
284
285 #[tokio::test]
286 async fn test_download_image_success() {
287 let mut server = Server::new_async().await;
288 let mock = server
289 .mock("GET", "/test.jpg")
290 .with_status(200)
291 .with_header("content-type", "image/jpeg")
292 .with_body("fake image data")
293 .create_async()
294 .await;
295
296 let temp_dir = TempDir::new().unwrap();
297 let image =
298 create_test_image_with_url("test.jpg", "key123", &format!("{}/test.jpg", server.url()));
299
300 let client = reqwest::Client::new();
301 let result = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
302
303 assert!(result.is_ok());
304 assert_eq!(result.unwrap(), 15); let output_path = temp_dir.path().join("test.jpg");
308 assert!(output_path.exists());
309
310 let content = fs::read_to_string(&output_path).await.unwrap();
312 assert_eq!(content, "fake image data");
313
314 mock.assert_async().await;
315 }
316
317 #[tokio::test]
318 async fn test_download_image_http_error() {
319 let mut server = Server::new_async().await;
320 let mock = server
321 .mock("GET", "/error.jpg")
322 .with_status(404)
323 .with_body("Not Found")
324 .create_async()
325 .await;
326
327 let temp_dir = TempDir::new().unwrap();
328 let image = create_test_image_with_url(
329 "error.jpg",
330 "key456",
331 &format!("{}/error.jpg", server.url()),
332 );
333
334 let client = reqwest::Client::new();
335 let result = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
336
337 assert!(result.is_err());
338 assert!(
339 result
340 .unwrap_err()
341 .to_string()
342 .contains("Download failed with status: 404")
343 );
344
345 let output_path = temp_dir.path().join("error.jpg");
347 assert!(!output_path.exists());
348
349 mock.assert_async().await;
350 }
351
352 #[tokio::test]
353 async fn test_download_image_file_conflict_handling() {
354 let mut server = Server::new_async().await;
355 let mock = server
356 .mock("GET", "/image.jpg")
357 .with_status(200)
358 .with_body("test data")
359 .expect(3)
360 .create_async()
361 .await;
362
363 let temp_dir = TempDir::new().unwrap();
364 let image = create_test_image_with_url(
365 "image.jpg",
366 "key789",
367 &format!("{}/image.jpg", server.url()),
368 );
369
370 let client = reqwest::Client::new();
371
372 let result1 = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
374 assert!(result1.is_ok());
375 assert!(temp_dir.path().join("image.jpg").exists());
376
377 let result2 = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
379 assert!(result2.is_ok());
380 assert!(temp_dir.path().join("image_1.jpg").exists());
381
382 let result3 = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
384 assert!(result3.is_ok());
385
386 assert!(temp_dir.path().join("image.jpg").exists());
388 assert!(temp_dir.path().join("image_1.jpg").exists());
389 assert!(result3.is_ok(), "Third download should succeed");
391
392 mock.assert_async().await;
393 }
394
395 #[tokio::test]
396 async fn test_download_image_file_conflict_no_extension() {
397 let mut server = Server::new_async().await;
398 let mock = server
399 .mock("GET", "/noext")
400 .with_status(200)
401 .with_body("data")
402 .expect(2)
403 .create_async()
404 .await;
405
406 let temp_dir = TempDir::new().unwrap();
407 let image =
408 create_test_image_with_url("noext", "key999", &format!("{}/noext", server.url()));
409
410 let client = reqwest::Client::new();
411
412 let result1 = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
414 assert!(result1.is_ok());
415 assert!(temp_dir.path().join("noext").exists());
416
417 let result2 = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
419 assert!(result2.is_ok());
420 assert!(temp_dir.path().join("noext_1").exists());
421
422 mock.assert_async().await;
423 }
424
425 #[tokio::test]
426 async fn test_download_image_preserves_file_size() {
427 let mut server = Server::new_async().await;
428 let test_data = "a".repeat(10000); let mock = server
430 .mock("GET", "/large.jpg")
431 .with_status(200)
432 .with_body(&test_data)
433 .create_async()
434 .await;
435
436 let temp_dir = TempDir::new().unwrap();
437 let image = create_test_image_with_url(
438 "large.jpg",
439 "keyabc",
440 &format!("{}/large.jpg", server.url()),
441 );
442
443 let client = reqwest::Client::new();
444 let result = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
445
446 assert!(result.is_ok());
447 assert_eq!(result.unwrap(), 10000);
448
449 let output_path = temp_dir.path().join("large.jpg");
451 let metadata = fs::metadata(&output_path).await.unwrap();
452 assert_eq!(metadata.len(), 10000);
453
454 mock.assert_async().await;
455 }
456
457 #[tokio::test]
458 async fn test_download_image_complex_filename() {
459 let mut server = Server::new_async().await;
460 let mock = server
461 .mock("GET", "/file")
462 .with_status(200)
463 .with_body("data")
464 .create_async()
465 .await;
466
467 let temp_dir = TempDir::new().unwrap();
468 let image = create_test_image_with_url(
469 "my.photo.with.dots.jpg",
470 "keydef",
471 &format!("{}/file", server.url()),
472 );
473
474 let client = reqwest::Client::new();
475 let result = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
476
477 assert!(result.is_ok());
478
479 let output_path = temp_dir.path().join("my.photo.with.dots.jpg");
481 assert!(output_path.exists());
482
483 mock.assert_async().await;
484 }
485
486 #[tokio::test]
487 async fn test_download_image_conflict_with_complex_filename() {
488 let mut server = Server::new_async().await;
489 let mock = server
490 .mock("GET", "/file")
491 .with_status(200)
492 .with_body("data")
493 .expect(2)
494 .create_async()
495 .await;
496
497 let temp_dir = TempDir::new().unwrap();
498 let image = create_test_image_with_url(
499 "photo.backup.tar.gz",
500 "keyghi",
501 &format!("{}/file", server.url()),
502 );
503
504 let client = reqwest::Client::new();
505
506 let result1 = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
508 assert!(result1.is_ok());
509 assert!(temp_dir.path().join("photo.backup.tar.gz").exists());
510
511 let result2 = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
513 assert!(result2.is_ok());
514 assert!(temp_dir.path().join("photo.backup.tar_1.gz").exists());
515
516 mock.assert_async().await;
517 }
518
519 #[tokio::test]
520 async fn test_download_image_empty_response() {
521 let mut server = Server::new_async().await;
522 let mock = server
523 .mock("GET", "/empty.jpg")
524 .with_status(200)
525 .with_body("")
526 .create_async()
527 .await;
528
529 let temp_dir = TempDir::new().unwrap();
530 let image = create_test_image_with_url(
531 "empty.jpg",
532 "keyjkl",
533 &format!("{}/empty.jpg", server.url()),
534 );
535
536 let client = reqwest::Client::new();
537 let result = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
538
539 assert!(result.is_ok());
540 assert_eq!(result.unwrap(), 0);
541
542 let output_path = temp_dir.path().join("empty.jpg");
544 assert!(output_path.exists());
545 let metadata = fs::metadata(&output_path).await.unwrap();
546 assert_eq!(metadata.len(), 0);
547
548 mock.assert_async().await;
549 }
550
551 #[tokio::test]
552 async fn test_download_image_creates_parent_directory() {
553 let mut server = Server::new_async().await;
554 let mock = server
555 .mock("GET", "/test.jpg")
556 .with_status(200)
557 .with_body("data")
558 .create_async()
559 .await;
560
561 let temp_dir = TempDir::new().unwrap();
562
563 let output_dir = temp_dir.path().to_path_buf();
565 fs::create_dir_all(&output_dir).await.unwrap();
566
567 let image =
568 create_test_image_with_url("test.jpg", "keymno", &format!("{}/test.jpg", server.url()));
569
570 let client = reqwest::Client::new();
571 let result = download_image(&client, &image, &output_dir).await;
572
573 assert!(result.is_ok());
574
575 let output_path = output_dir.join("test.jpg");
576 assert!(output_path.exists());
577
578 mock.assert_async().await;
579 }
580
581 #[test]
582 fn test_download_stats_all_successful() {
583 let stats = DownloadStats {
584 total_images: 10,
585 downloaded: 10,
586 failed: 0,
587 total_bytes: 5000000,
588 };
589
590 assert_eq!(stats.total_images, stats.downloaded);
591 assert_eq!(stats.failed, 0);
592 assert!(stats.total_bytes > 0);
593 }
594
595 #[test]
596 fn test_download_stats_partial_failure() {
597 let stats = DownloadStats {
598 total_images: 10,
599 downloaded: 7,
600 failed: 3,
601 total_bytes: 3500000,
602 };
603
604 assert_eq!(stats.total_images, stats.downloaded + stats.failed);
605 assert!(stats.failed > 0);
606 assert!(stats.downloaded > stats.failed);
607 }
608
609 #[test]
610 fn test_download_stats_all_failed() {
611 let stats = DownloadStats {
612 total_images: 5,
613 downloaded: 0,
614 failed: 5,
615 total_bytes: 0,
616 };
617
618 assert_eq!(stats.total_images, stats.failed);
619 assert_eq!(stats.downloaded, 0);
620 assert_eq!(stats.total_bytes, 0);
621 }
622
623 #[tokio::test]
624 async fn test_download_image_with_special_characters_in_filename() {
625 let mut server = Server::new_async().await;
626 let mock = server
627 .mock("GET", "/file")
628 .with_status(200)
629 .with_body("data")
630 .create_async()
631 .await;
632
633 let temp_dir = TempDir::new().unwrap();
634 let image = create_test_image_with_url(
637 "photo (1).jpg",
638 "keypqr",
639 &format!("{}/file", server.url()),
640 );
641
642 let client = reqwest::Client::new();
643 let result = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
644
645 assert!(result.is_ok());
646
647 let output_path = temp_dir.path().join("photo (1).jpg");
648 assert!(output_path.exists());
649
650 mock.assert_async().await;
651 }
652
653 #[tokio::test]
654 async fn test_download_image_server_error() {
655 let mut server = Server::new_async().await;
656 let mock = server
657 .mock("GET", "/server_error.jpg")
658 .with_status(500)
659 .with_body("Internal Server Error")
660 .create_async()
661 .await;
662
663 let temp_dir = TempDir::new().unwrap();
664 let image = create_test_image_with_url(
665 "server_error.jpg",
666 "keystu",
667 &format!("{}/server_error.jpg", server.url()),
668 );
669
670 let client = reqwest::Client::new();
671 let result = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
672
673 assert!(result.is_err());
674 assert!(
675 result
676 .unwrap_err()
677 .to_string()
678 .contains("Download failed with status: 500")
679 );
680
681 mock.assert_async().await;
682 }
683
684 #[tokio::test]
685 async fn test_download_image_bytes_counting() {
686 let mut server = Server::new_async().await;
687
688 let mock1 = server
690 .mock("GET", "/small.jpg")
691 .with_status(200)
692 .with_body("x")
693 .create_async()
694 .await;
695
696 let temp_dir1 = TempDir::new().unwrap();
697 let image1 =
698 create_test_image_with_url("small.jpg", "1", &format!("{}/small.jpg", server.url()));
699
700 let client = reqwest::Client::new();
701 let result1 = download_image(&client, &image1, &temp_dir1.path().to_path_buf()).await;
702 assert!(result1.is_ok());
703 assert_eq!(result1.unwrap(), 1);
704 mock1.assert_async().await;
705
706 let medium_content = "y".repeat(100);
708 let mock2 = server
709 .mock("GET", "/medium.jpg")
710 .with_status(200)
711 .with_body(&medium_content)
712 .create_async()
713 .await;
714
715 let temp_dir2 = TempDir::new().unwrap();
716 let image2 =
717 create_test_image_with_url("medium.jpg", "2", &format!("{}/medium.jpg", server.url()));
718
719 let result2 = download_image(&client, &image2, &temp_dir2.path().to_path_buf()).await;
720 assert!(result2.is_ok());
721 assert_eq!(result2.unwrap(), 100);
722 mock2.assert_async().await;
723
724 let large_content = "z".repeat(5000);
726 let mock3 = server
727 .mock("GET", "/large.jpg")
728 .with_status(200)
729 .with_body(&large_content)
730 .create_async()
731 .await;
732
733 let temp_dir3 = TempDir::new().unwrap();
734 let image3 =
735 create_test_image_with_url("large.jpg", "3", &format!("{}/large.jpg", server.url()));
736
737 let result3 = download_image(&client, &image3, &temp_dir3.path().to_path_buf()).await;
738 assert!(result3.is_ok());
739 assert_eq!(result3.unwrap(), 5000);
740 mock3.assert_async().await;
741 }
742}