Skip to main content

smugmug_cli/downloader/
mod.rs

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    // List images in the album
28    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    // Create output directory if it doesn't exist
47    fs::create_dir_all(&options.output_dir)
48        .await
49        .context("Failed to create output directory")?;
50
51    // Create download queue
52    let mut queue = VecDeque::new();
53    for image in &images {
54        queue.push_back(image.clone());
55    }
56
57    // Setup progress bars
58    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    // Track statistics
70    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    // Process images with concurrent workers
78    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                // Get next image from queue
93                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                // Download the image
103                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    // Wait for all workers to complete
125    for handle in handles {
126        handle.await?;
127    }
128
129    overall_progress.finish_with_message("Download complete");
130
131    // Return final statistics
132    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    // Download the image from the archived URI
147    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    // Determine output file path, handling filename conflicts
165    let mut output_path = output_dir.join(&image.file_name);
166    let mut counter = 1;
167
168    while output_path.exists() {
169        // Extract extension and base name
170        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        // Create new filename with counter
180        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    // Write the file
191    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    // Helper function to create a test AlbumImage
207    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    // Helper function to create a test AlbumImage with a custom URL
221    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); // "fake image data" is 15 bytes
305
306        // Verify file was created
307        let output_path = temp_dir.path().join("test.jpg");
308        assert!(output_path.exists());
309
310        // Verify file content
311        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        // Verify file was not created
346        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        // First download - should create image.jpg
373        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        // Second download - should create image_1.jpg
378        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        // Third download - should create image_1_1.jpg (since image_1.jpg exists)
383        let result3 = download_image(&client, &image, &temp_dir.path().to_path_buf()).await;
384        assert!(result3.is_ok());
385
386        // Verify first two files exist (third file name depends on implementation)
387        assert!(temp_dir.path().join("image.jpg").exists());
388        assert!(temp_dir.path().join("image_1.jpg").exists());
389        // Third file should exist (name may vary based on conflict resolution)
390        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        // First download - should create noext
413        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        // Second download - should create noext_1
418        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); // 10KB of data
429        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        // Verify file size
450        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        // File should be created with the specified name
480        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        // First download
507        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        // Second download - should create photo.backup.tar_1.gz
512        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        // Verify empty file was created
543        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        // Ensure the directory exists before passing it to download_image
564        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        // Note: Some special characters may not be valid in filenames
635        // This tests that the system can handle typical filename variations
636        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        // Test small file (1 byte)
689        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        // Test medium file (100 bytes)
707        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        // Test large file (5000 bytes)
725        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}