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
31pub 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
42async 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
61struct 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 pub fn config(&self) -> &config::Config {
123 &self.config
124 }
125
126 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 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 let max_concurrent = self.config.max_concurrent_downloads as usize;
259 let m = MultiProgress::new(); 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 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 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 pub async fn remove(&mut self, ids_to_remove: &[String]) -> Result<()> {
364 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 let original_len = self.wallpapers.len();
390
391 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_wallpaper_list(&self.wallpapers, &self.wallpapers_list_file_location).await?;
403
404 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 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 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
634enum WallpaperStatus {
636 Downloaded { path: PathBuf },
637 NotDownloaded,
638}
639
640async 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 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 Ok(WallpaperStatus::Downloaded {
656 path: existing_path,
657 })
658 } else {
659 Ok(WallpaperStatus::NotDownloaded)
660 }
661}
662
663async 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
683async 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
699async 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); 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}