Skip to main content

rust_paper/
lib.rs

1use anyhow::{Context, Result};
2use futures::stream::{self, FuturesUnordered, StreamExt};
3use indicatif::MultiProgress;
4use reqwest::Client;
5use serde_json::Value;
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::Duration;
10use tokio::fs::{create_dir_all, File};
11use tokio::io::{AsyncBufReadExt, BufReader};
12use tokio::sync::{Mutex, Semaphore};
13use tokio::time::sleep;
14
15mod api;
16mod args;
17mod config;
18mod helper;
19mod lock;
20
21use lock::LockFile;
22
23use crate::helper::{get_key_from_config_or_env, update_wallpaper_list};
24
25pub use api::{WallhavenClient, WallhavenClientError};
26pub use args::{Cli, Command};
27
28pub const WALLHAVEN_API: &str = "https://wallhaven.cc/api/v1/w";
29pub const WALLHAVEN_BASE: &str = "https://wallhaven.cc/w";
30
31/// Main RustPaper struct for managing wallpapers
32pub struct RustPaper {
33    pub config: config::Config,
34    pub config_folder: PathBuf,
35    pub wallpapers: Vec<String>,
36    pub wallpapers_list_file_location: PathBuf,
37    pub lock_file: Arc<Mutex<Option<LockFile>>>,
38    pub http_client: Client,
39    pub download_semaphore: Arc<Semaphore>,
40}
41
42/// INFO: Build a map of wallpaper IDs to file paths (cached directory listing)
43async fn build_file_map(save_location: &str) -> Result<HashMap<String, PathBuf>> {
44    let save_path = Path::new(save_location);
45    let mut file_map = HashMap::new();
46    if !save_path.exists() {
47        return Ok(file_map);
48    }
49    let mut entries = tokio::fs::read_dir(save_path).await?;
50    while let Some(entry) = entries.next_entry().await? {
51        let path = entry.path();
52        if path.is_file() {
53            if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
54                file_map.insert(file_stem.to_string(), path);
55            }
56        }
57    }
58    Ok(file_map)
59}
60
61/// Result of processing a wallpaper (for batch lock file updates)
62struct ProcessResult {
63    wallpaper_id: String,
64    image_location: String,
65    sha256: Option<String>,
66}
67
68async fn process_wallpaper_optimized(
69    config: &config::Config,
70    wallpaper: &str,
71    client: &Client,
72    show_progress: bool,
73    multi_progress: Option<MultiProgress>,
74) -> Result<ProcessResult> {
75    let img_link: String = if let Some(api_key) = config.api_key.as_deref() {
76        let wallhaven_img_link = format!("{}/{}", WALLHAVEN_API, wallpaper.trim());
77        let curl_data = retry_get_curl_content(
78            &wallhaven_img_link,
79            client,
80            Some(api_key),
81            config.retry_count,
82        )
83        .await?;
84        let res: Value = serde_json::from_str(&curl_data)?;
85        if let Some(error) = res.get("error") {
86            eprintln!("Error : {}", error);
87            return Err(anyhow::anyhow!("❌ API error: {}", error));
88        }
89        res.get("data")
90            .and_then(|data| data.get("path"))
91            .and_then(Value::as_str)
92            .ok_or_else(|| anyhow::anyhow!("Failed to get image link from API response"))?
93            .to_string()
94    } else {
95        let wallhaven_img_link = format!("{}/{}", WALLHAVEN_BASE, wallpaper.trim());
96        let curl_data =
97            retry_get_curl_content(&wallhaven_img_link, client, None, config.retry_count).await?;
98        helper::scrape_img_link(curl_data)?
99    };
100    match helper::download_with_progress(
101        &img_link,
102        wallpaper,
103        &config.save_location,
104        client,
105        config.integrity,
106        show_progress,
107        multi_progress,
108    )
109    .await
110    {
111        Ok(result) => Ok(ProcessResult {
112            wallpaper_id: wallpaper.to_string(),
113            image_location: result.file_path,
114            sha256: result.sha256,
115        }),
116        Err(e) => Err(anyhow::anyhow!("Failed to download {}: {}", &wallpaper, e)),
117    }
118}
119
120impl RustPaper {
121    /// Get a reference to the configuration
122    pub fn config(&self) -> &config::Config {
123        &self.config
124    }
125
126    /// Create a new RustPaper instance with loaded configuration
127    pub async fn new() -> Result<Self> {
128        let config: config::Config =
129            confy::load("rust-paper", "config").context("   Failed to load configuration")?;
130
131        let config_folder = helper::get_folder_path().context("   Failed to get folder path")?;
132
133        tokio::try_join!(
134            create_dir_all(&config_folder),
135            create_dir_all(&config.save_location)
136        )?;
137
138        let wallpapers_list_file_location = config_folder.join("wallpapers.lst");
139        let wallpapers = load_wallpapers(&wallpapers_list_file_location).await?;
140
141        let lock_file = if config.integrity {
142            Some(LockFile::load_or_new().await)
143        } else {
144            None
145        };
146        let api_key = get_key_from_config_or_env(config.api_key.as_deref());
147        let http_client = helper::create_http_client(config.timeout, api_key.as_ref())?;
148        let download_semaphore = Arc::new(Semaphore::new(config.max_concurrent_downloads));
149
150        Ok(Self {
151            config,
152            config_folder,
153            wallpapers,
154            wallpapers_list_file_location,
155            lock_file: Arc::new(Mutex::new(lock_file)),
156            http_client,
157            download_semaphore,
158        })
159    }
160
161    /// Sync all wallpapers in the list
162    pub async fn sync(&self) -> Result<()> {
163        let file_map = build_file_map(&self.config.save_location).await?;
164        let lock_file_map: Option<HashMap<String, (String, String)>> = if self.config.integrity {
165            let lock_file_guard = self.lock_file.lock().await;
166            if let Some(ref lock_file) = *lock_file_guard {
167                Some(
168                    lock_file
169                        .entries()
170                        .iter()
171                        .map(|e| {
172                            (
173                                e.image_id().to_string(),
174                                (e.image_location().to_string(), e.image_sha256().to_string()),
175                            )
176                        })
177                        .collect(),
178                )
179            } else {
180                None
181            }
182        } else {
183            None
184        };
185
186        let mut needs_download = Vec::new();
187        let mut integrity_checks = Vec::new();
188        for wallpaper in &self.wallpapers {
189            if let Some(existing_path) = file_map.get(wallpaper) {
190                if self.config.integrity {
191                    if let Some(ref lock_map) = lock_file_map {
192                        if let Some((lock_location, expected_sha256)) = lock_map.get(wallpaper) {
193                            let path_str = existing_path.to_string_lossy().to_string();
194                            if lock_location == &path_str {
195                                integrity_checks.push((
196                                    wallpaper.clone(),
197                                    existing_path.clone(),
198                                    expected_sha256.clone(),
199                                ));
200                                continue;
201                            }
202                        }
203                    }
204                    needs_download.push(wallpaper.clone());
205                } else {
206                    println!("   Skipping {}: already exists", wallpaper);
207                }
208            } else {
209                needs_download.push(wallpaper.clone());
210            }
211        }
212
213        if !integrity_checks.is_empty() {
214            let check_tasks: FuturesUnordered<_> = integrity_checks
215                .into_iter()
216                .map(|(wallpaper_id, path, expected_hash)| {
217                    tokio::spawn(async move {
218                        match helper::calculate_sha256(&path).await {
219                            Ok(actual_sha256) => {
220                                if actual_sha256 == expected_hash {
221                                    Ok::<(String, bool), anyhow::Error>((wallpaper_id, false))
222                                } else {
223                                    println!(
224                                        "   Integrity check failed for {}: re-downloading",
225                                        wallpaper_id
226                                    );
227                                    Ok::<(String, bool), anyhow::Error>((wallpaper_id, true))
228                                }
229                            }
230                            Err(_) => Ok::<(String, bool), anyhow::Error>((wallpaper_id, true)),
231                        }
232                    })
233                })
234                .collect();
235
236            let mut check_tasks = check_tasks;
237            while let Some(result) = check_tasks.next().await {
238                match result {
239                    Ok(Ok((wallpaper_id, should_download))) => {
240                        if should_download {
241                            needs_download.push(wallpaper_id);
242                        }
243                    }
244                    _ => {
245                        unreachable!()
246                    }
247                }
248            }
249        }
250
251        if needs_download.is_empty() {
252            println!("   All wallpapers are up to date.");
253            return Ok(());
254        }
255        println!("Downloading {} wallpapers...", needs_download.len());
256
257        // --- FIX STARTS HERE ---
258        let max_concurrent = self.config.max_concurrent_downloads as usize;
259        let m = MultiProgress::new(); // Supervisor for all bars
260        let mut tasks = stream::iter(needs_download.iter())
261            .map(|w| {
262                let client = self.http_client.clone();
263                let config = self.config.clone();
264                let mp = m.clone();
265                async move {
266                    let res =
267                        process_wallpaper_optimized(&config, w, &client, true, Some(mp)).await;
268                    (w, res)
269                }
270            })
271            .buffer_unordered(max_concurrent);
272
273        let mut errors = 0;
274        let mut completed = 0;
275        let total = needs_download.len();
276        let mut lock_file_updates = Vec::new();
277
278        while let Some((w, result)) = tasks.next().await {
279            completed += 1;
280            match result {
281                Ok(process_result) => {
282                    let _ = m.println(format!(
283                        "  ✓ Downloaded {} - {}",
284                        w, process_result.image_location
285                    ));
286                    if self.config.integrity {
287                        if let Some(sha256) = process_result.sha256 {
288                            lock_file_updates.push((
289                                process_result.wallpaper_id,
290                                process_result.image_location,
291                                sha256,
292                            ));
293                        }
294                    }
295                }
296                Err(e) => {
297                    let _ = m.println(format!("  ✗ Failed: {}", e));
298                    errors += 1;
299                }
300            }
301        }
302
303        if self.config.integrity && !lock_file_updates.is_empty() {
304            let mut lock_file_guard = self.lock_file.lock().await;
305            if let Some(ref mut lock_file) = *lock_file_guard {
306                for (image_id, image_location, sha256) in lock_file_updates {
307                    lock_file.add_entry(image_id, image_location, sha256);
308                }
309                lock_file.save().await?;
310            }
311        }
312        if errors > 0 {
313            eprintln!(
314                "✔️ Completed {} of {} with {} error(s)",
315                completed, total, errors
316            );
317        } else {
318            println!("\n ✅ Sync complete!");
319        }
320
321        Ok(())
322    }
323
324    /// Add new wallpapers to the list
325    pub async fn add(&mut self, new_wallpapers: &mut Vec<String>) -> Result<()> {
326        *new_wallpapers = new_wallpapers
327            .iter()
328            .map(|wall| {
329                if helper::is_url(wall) {
330                    wall.split('/')
331                        .last()
332                        .unwrap_or_default()
333                        .split('?')
334                        .next()
335                        .unwrap_or_default()
336                        .to_string()
337                } else {
338                    wall.to_string()
339                }
340            })
341            .collect();
342
343        // Validate wallpaper IDs
344        let mut valid_wallpapers = Vec::new();
345        for wallpaper in new_wallpapers.iter().flat_map(|s| helper::to_array(s)) {
346            if helper::validate_wallpaper_id(&wallpaper) {
347                valid_wallpapers.push(wallpaper);
348            } else {
349                eprintln!(
350                    "‼️ Warning: Invalid wallpaper ID format '{}', skipping",
351                    wallpaper
352                );
353            }
354        }
355
356        self.wallpapers.extend(valid_wallpapers);
357        self.wallpapers.sort_unstable();
358        self.wallpapers.dedup();
359        update_wallpaper_list(&self.wallpapers, &self.wallpapers_list_file_location).await
360    }
361
362    /// Remove wallpapers from the list
363    pub async fn remove(&mut self, ids_to_remove: &[String]) -> Result<()> {
364        // Extract and validate wallpaper IDs (support URLs and comma-separated)
365        let ids: Vec<String> = ids_to_remove
366            .iter()
367            .flat_map(|id| {
368                let processed = if helper::is_url(id) {
369                    id.split('/')
370                        .last()
371                        .unwrap_or_default()
372                        .split('?')
373                        .next()
374                        .unwrap_or_default()
375                        .to_string()
376                } else {
377                    id.clone()
378                };
379                helper::to_array(&processed)
380            })
381            .filter(|id| helper::validate_wallpaper_id(id))
382            .collect();
383
384        if ids.is_empty() {
385            return Err(anyhow::anyhow!("No valid wallpaper IDs provided"));
386        }
387
388        // Track what was removed
389        let original_len = self.wallpapers.len();
390
391        // Remove IDs from the list
392        self.wallpapers.retain(|id| !ids.contains(id));
393
394        let removed_count = original_len - self.wallpapers.len();
395
396        if removed_count == 0 {
397            println!("   No matching wallpaper IDs found in the list");
398            return Ok(());
399        }
400
401        // Update the wallpapers list file
402        update_wallpaper_list(&self.wallpapers, &self.wallpapers_list_file_location).await?;
403
404        // Optionally remove from lock file if integrity is enabled
405        if self.config.integrity {
406            let mut lock_file_guard = self.lock_file.lock().await;
407            if let Some(ref mut lock_file) = *lock_file_guard {
408                for id in &ids {
409                    lock_file.remove(id).await?;
410                }
411            }
412        }
413
414        if removed_count == ids.len() {
415            println!(
416                "   Removed {} wallpaper ID(s) from the list",
417                removed_count
418            );
419        } else {
420            println!(
421                "   Removed {} of {} requested wallpaper ID(s) from the list",
422                removed_count,
423                ids.len()
424            );
425        }
426
427        Ok(())
428    }
429
430    /// List all tracked wallpapers with their download status
431    pub async fn list(&self) -> Result<()> {
432        if self.wallpapers.is_empty() {
433            println!("   No wallpapers tracked.");
434            return Ok(());
435        }
436
437        println!("  Tracked wallpapers ({} total):", self.wallpapers.len());
438        println!();
439
440        let mut downloaded_count = 0;
441        let mut not_downloaded_count = 0;
442
443        for wallpaper_id in &self.wallpapers {
444            let status =
445                check_download_status(&self.config.save_location, wallpaper_id, &self.lock_file)
446                    .await?;
447
448            match status {
449                WallpaperStatus::Downloaded { path } => {
450                    println!("  ✓ {} - Downloaded ({})", wallpaper_id, path.display());
451                    downloaded_count += 1;
452                }
453                WallpaperStatus::NotDownloaded => {
454                    println!("  ○ {} - Not downloaded", wallpaper_id);
455                    not_downloaded_count += 1;
456                }
457            }
458        }
459
460        println!();
461        println!(
462            "  Summary: {} downloaded, {} not downloaded",
463            downloaded_count, not_downloaded_count
464        );
465
466        Ok(())
467    }
468
469    /// Clean up downloaded wallpapers that are no longer in the list
470    pub async fn clean(&mut self) -> Result<()> {
471        let save_location = Path::new(&self.config.save_location);
472        if !save_location.exists() {
473            println!(
474                "  Save location does not exist: {}",
475                save_location.display()
476            );
477            return Ok(());
478        }
479        let mut entries = tokio::fs::read_dir(save_location).await?;
480        let mut removed_count = 0;
481        let mut total_size = 0u64;
482        let mut files_to_check = Vec::new();
483        while let Some(entry) = entries.next_entry().await? {
484            let path = entry.path();
485            if path.is_file() {
486                if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
487                    files_to_check.push((path.clone(), file_stem.to_string()));
488                }
489            }
490        }
491        println!(
492            "  Checking {} file(s) in save location...",
493            files_to_check.len()
494        );
495        for (file_path, file_stem) in files_to_check {
496            if !self.wallpapers.contains(&file_stem) {
497                if let Ok(metadata) = tokio::fs::metadata(&file_path).await {
498                    total_size += metadata.len();
499                }
500                if self.config.integrity {
501                    let mut lock_file_guard = self.lock_file.lock().await;
502                    if let Some(ref mut lock_file) = *lock_file_guard {
503                        lock_file.remove(&file_stem).await?;
504                    }
505                }
506                match tokio::fs::remove_file(&file_path).await {
507                    Ok(_) => {
508                        println!("   Removed: {} ({})", file_stem, file_path.display());
509                        removed_count += 1;
510                    }
511                    Err(e) => {
512                        eprintln!("   Error removing {}: {}", file_path.display(), e);
513                    }
514                }
515            }
516        }
517
518        if removed_count == 0 {
519            println!("   No orphaned files found. Everything is clean!");
520        } else {
521            println!();
522            println!(
523                "  Cleaned up {} file(s), freed approximately {:.2} MB",
524                removed_count,
525                total_size as f64 / 1_048_576.0
526            );
527        }
528
529        Ok(())
530    }
531
532    pub async fn info(&self, id: &str) -> Result<()> {
533        let wallpaper_id = if helper::is_url(id) {
534            id.split('/')
535                .last()
536                .unwrap_or_default()
537                .split('?')
538                .next()
539                .unwrap_or_default()
540                .to_string()
541        } else {
542            id.to_string()
543        };
544
545        if !helper::validate_wallpaper_id(&wallpaper_id) {
546            return Err(anyhow::anyhow!(
547                "Invalid wallpaper ID format: '{}'",
548                wallpaper_id
549            ));
550        }
551
552        let api_url = format!("{}/{}", WALLHAVEN_API, wallpaper_id);
553        let response_data = retry_get_curl_content(
554            &api_url,
555            &self.http_client,
556            self.config.api_key.as_deref(),
557            self.config.retry_count,
558        )
559        .await?;
560        let json: Value = serde_json::from_str(&response_data)?;
561        if let Some(error) = json.get("error") {
562            return Err(anyhow::anyhow!("API error: {}", error));
563        }
564        if let Some(data) = json.get("data") {
565            println!("  Wallpaper Information:");
566            println!("  ─────────────────────");
567            if let Some(id_val) = data.get("id").and_then(Value::as_str) {
568                println!("  ID: {}", id_val);
569            }
570            if let Some(url) = data.get("url").and_then(Value::as_str) {
571                println!("  URL: {}", url);
572            }
573            if let Some(width) = data.get("resolution").and_then(Value::as_str) {
574                println!("  Resolution: {}", width);
575            }
576            if let Some(size) = data.get("file_size").and_then(Value::as_u64) {
577                println!("  File Size: {:.2} MB", size as f64 / 1_048_576.0);
578            }
579            if let Some(category) = data.get("category").and_then(Value::as_str) {
580                println!("  Category: {}", category);
581            }
582            if let Some(purity) = data.get("purity").and_then(Value::as_str) {
583                println!("  Purity: {}", purity);
584            }
585            if let Some(views) = data.get("views").and_then(Value::as_u64) {
586                println!("  Views: {}", views);
587            }
588            if let Some(favorites) = data.get("favorites").and_then(Value::as_u64) {
589                println!("  Favorites: {}", favorites);
590            }
591            if let Some(date) = data.get("created_at").and_then(Value::as_str) {
592                println!("  Uploaded: {}", date);
593            }
594            if let Some(uploader) = data.get("uploader") {
595                if let Some(username) = uploader.get("username").and_then(Value::as_str) {
596                    println!("  Uploader: {}", username);
597                }
598            }
599            if let Some(tags) = data.get("tags").and_then(Value::as_array) {
600                if !tags.is_empty() {
601                    let tag_names: Vec<String> = tags
602                        .iter()
603                        .filter_map(|tag| tag.get("name").and_then(Value::as_str))
604                        .map(String::from)
605                        .collect();
606                    if !tag_names.is_empty() {
607                        println!("  Tags: {}", tag_names.join(", "));
608                    }
609                }
610            }
611            if let Some(path) = data.get("path").and_then(Value::as_str) {
612                println!("  Image URL: {}", path);
613            }
614            if self.wallpapers.contains(&wallpaper_id) {
615                println!("  Status: Tracked");
616                if let Some(local_path) =
617                    find_existing_image(&self.config.save_location, &wallpaper_id).await?
618                {
619                    println!("  Local: {}", local_path.display());
620                } else {
621                    println!("  Local: Not downloaded");
622                }
623            } else {
624                println!("  Status: Not tracked");
625            }
626        } else {
627            return Err(anyhow::anyhow!("Invalid API response: no data field"));
628        }
629
630        Ok(())
631    }
632}
633
634/// Status of a wallpaper
635enum WallpaperStatus {
636    Downloaded { path: PathBuf },
637    NotDownloaded,
638}
639
640/// Check the download status of a wallpaper
641async fn check_download_status(
642    save_location: &str,
643    wallpaper_id: &str,
644    lock_file: &Arc<Mutex<Option<LockFile>>>,
645) -> Result<WallpaperStatus> {
646    if let Some(existing_path) = find_existing_image(save_location, wallpaper_id).await? {
647        // Check if integrity is enabled and verified
648        let lock_file_guard = lock_file.lock().await;
649        if let Some(_) = *lock_file_guard {
650            return Ok(WallpaperStatus::Downloaded {
651                path: existing_path,
652            });
653        }
654        // File exists but integrity is not enabled
655        Ok(WallpaperStatus::Downloaded {
656            path: existing_path,
657        })
658    } else {
659        Ok(WallpaperStatus::NotDownloaded)
660    }
661}
662
663/// Load wallpaper IDs from a file
664async fn load_wallpapers(given_file: impl AsRef<Path>) -> Result<Vec<String>> {
665    let file_path = given_file.as_ref();
666    if !file_path.exists() {
667        File::create(file_path).await?;
668        return Ok(vec![]);
669    }
670
671    let file = File::open(file_path).await?;
672    let reader = BufReader::new(file);
673    let mut lines = Vec::new();
674    let mut lines_stream = reader.lines();
675
676    while let Some(line) = lines_stream.next_line().await? {
677        lines.extend(helper::to_array(&line));
678    }
679
680    Ok(lines)
681}
682
683/// Find an existing image file for a wallpaper ID
684async fn find_existing_image(
685    save_location_given: impl AsRef<Path>,
686    wallpaper: &str,
687) -> Result<Option<PathBuf>> {
688    let save_location = save_location_given.as_ref();
689    let mut entries = tokio::fs::read_dir(save_location).await?;
690    while let Some(entry) = entries.next_entry().await? {
691        let path = entry.path();
692        if path.file_stem().and_then(|s| s.to_str()) == Some(wallpaper) {
693            return Ok(Some(path));
694        }
695    }
696    Ok(None)
697}
698
699/// Retry fetching content from a URL with exponential backoff
700async fn retry_get_curl_content(
701    url: &str,
702    client: &Client,
703    api_key: Option<&str>,
704    max_retry: u32,
705) -> Result<String> {
706    for retry_count in 0..max_retry {
707        match helper::get_curl_content(url, client, api_key).await {
708            Ok(content) => return Ok(content),
709            Err(e) if retry_count + 1 < max_retry => {
710                let delay = 2_u64.pow(retry_count); // Exponential backoff
711                eprintln!(
712                    "   Error fetching content (attempt {} of {}): {}. Retrying in {}s...",
713                    retry_count + 1,
714                    max_retry,
715                    e,
716                    delay
717                );
718                sleep(Duration::from_secs(delay)).await;
719            }
720            Err(e) => return Err(e),
721        }
722    }
723    unreachable!()
724}