1use crate::RustPaper;
2use anyhow::{Context, Error};
3use futures::stream::{self, StreamExt};
4
5pub const BASE_URL: &str = "https://wallhaven.cc/api/v1";
6
7use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Serialize, Deserialize)]
12pub struct SearchResponse {
13 #[serde(rename = "data")]
14 pub data: Vec<Wallpaper>,
15 #[serde(rename = "meta")]
16 pub meta: WallpaperMeta,
17}
18#[derive(Debug, Serialize, Deserialize, Clone)]
19#[serde(rename = "")]
20pub struct Wallpaper {
21 pub id: String,
22 pub url: String,
23 pub short_url: String,
24 pub views: i32,
25 pub favorites: i32,
26 pub source: String,
27 pub purity: String,
28 pub category: String,
29 pub dimension_x: i32,
30 pub dimension_y: i32,
31 pub resolution: String,
32 pub ratio: String,
33 pub file_size: i32,
34 pub file_type: String,
35 pub created_at: String,
36 pub colors: Vec<String>,
37 pub path: String,
38 pub thumbs: Thumbs,
39}
40#[derive(Debug, Serialize, Deserialize, Clone)]
41pub struct Thumbs {
42 large: String,
43 original: String,
44 small: String,
45}
46#[derive(Debug, Serialize, Deserialize, Clone)]
47#[serde(rename = "")]
48pub struct WallpaperMeta {
49 current_page: i32,
50 last_page: i32,
51 #[serde(deserialize_with = "serde_aux::field_attributes::deserialize_number_from_string")]
52 per_page: i32, total: i32,
54 query: MetaQuery,
55 seed: Option<String>,
56}
57#[derive(Debug, Serialize, Deserialize, Clone)]
58#[serde(untagged)]
59pub enum MetaQuery {
60 Query(Option<String>),
61 Querytag { id: i32, tag: Option<String> },
62}
63
64#[derive(Debug, Serialize, Deserialize, Clone)]
65#[serde(rename = "")]
66pub struct WallpaperInfoResponse {
67 #[serde(rename = "data")]
68 pub data: WallpaperInfo,
69}
70#[derive(Debug, Serialize, Deserialize, Clone)]
71#[serde(rename = "")]
72pub struct WallpaperInfo {
73 pub id: String,
74 pub url: String,
75 pub short_url: String,
76 pub uploader: Uploader,
77 pub views: i32,
78 pub favorites: i32,
79 pub source: String,
80 pub purity: String,
81 pub category: String,
82 pub dimension_x: i32,
83 pub dimension_y: i32,
84 pub resolution: String,
85 pub ratio: String,
86 pub file_size: i32,
87 pub file_type: String,
88 pub created_at: String,
89 pub colors: Vec<String>,
90 pub path: String,
91 pub thumbs: Thumbs,
92 pub tags: Vec<Tag>,
93}
94#[derive(Debug, Serialize, Deserialize, Clone)]
95pub struct Uploader {
96 pub username: String,
97 pub group: String,
98 pub avatar: Avatar,
99}
100#[derive(Debug, Serialize, Deserialize, Clone)]
101pub struct Avatar {
102 #[serde(rename = "200px")]
103 pub _200px: String,
104 #[serde(rename = "128px")]
105 pub _128px: String,
106 #[serde(rename = "32px")]
107 pub _32px: String,
108 #[serde(rename = "20px")]
109 pub _20px: String,
110}
111
112#[derive(Debug, Serialize, Deserialize, Clone)]
113#[serde(rename = "")]
114pub struct TagResponse {
115 #[serde(rename = "data")]
116 pub data: Tag,
117}
118#[derive(Debug, Serialize, Deserialize, Clone)]
119#[serde(rename = "")]
120pub struct Tag {
121 pub id: i32,
122 pub name: String,
123 pub alias: String,
124 pub category_id: i32,
125 pub category: String,
126 pub purity: String,
127 pub created_at: String,
128}
129
130#[derive(Debug, Serialize, Deserialize, Clone)]
131#[serde(rename = "")]
132pub struct UserSettingsResponse {
133 #[serde(rename = "data")]
134 pub data: UserSettings,
135}
136#[derive(Debug, Serialize, Deserialize, Clone)]
137#[serde(rename = "")]
138pub struct UserSettings {
139 pub thumb_size: String,
140 pub per_page: String,
141 pub purity: Vec<String>,
142 pub categories: Vec<String>,
143 pub resolutions: Vec<String>,
144 pub aspect_ratios: Vec<String>,
145 pub toplist_range: String,
146 pub tag_blacklist: Vec<String>,
147 pub user_blacklist: Vec<String>,
148}
149#[derive(Debug, Serialize, Deserialize, Clone)]
150#[serde(rename = "")]
151pub struct UserCollectionsResponse {
152 #[serde(rename = "data")]
153 pub data: Vec<UserCollections>,
154}
155#[derive(Debug, Serialize, Deserialize, Clone)]
156#[serde(rename = "")]
157pub struct UserCollections {
158 pub id: i32,
159 pub label: String,
160 pub views: i32,
161 pub public: i32,
162 pub count: i32,
163}
164
165#[derive(Debug, Serialize, Deserialize, Clone)]
166#[serde(rename = "")]
167pub struct ErrorResponse {
168 pub error: String,
169}
170
171pub(crate) trait Url {
172 fn to_url(&self, base_url: &str) -> String;
173}
174
175use futures::TryFutureExt;
176use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
177use std::time::Duration;
178use tokio::fs::OpenOptions;
179use tokio::io::AsyncWriteExt;
180use tokio::time::sleep;
181
182use crate::args::Command;
183use crate::helper::get_key_from_config_or_env;
184
185#[derive(Debug)]
186pub enum WallhavenClientError {
187 RequestError(String),
188 DecodeError(String),
189 WriteError(String),
190 Error(String),
191}
192
193impl std::fmt::Display for WallhavenClientError {
194 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195 match self {
196 Self::DecodeError(e) => {
197 write!(f, "Decode Error - {}", e)
198 }
199 Self::WriteError(e) => {
200 write!(f, "Write Error - {}", e)
201 }
202 Self::RequestError(e) => {
203 write!(f, "Request Error - {}", e)
204 }
205 Self::Error(e) => {
206 write!(f, "Error - {}", e)
207 }
208 }
209 }
210}
211
212impl std::error::Error for WallhavenClientError {}
213
214pub struct WallhavenClient {
215 http_client: reqwest::Client,
216 commands: Command,
217 rust_paper: RustPaper,
218}
219
220impl WallhavenClient {
221 pub async fn new(commands: Command) -> Result<Self, Error> {
222 let rust_paper = RustPaper::new().await?;
223 let api_key = get_key_from_config_or_env(rust_paper.config().api_key.as_deref());
224 if api_key.is_none() {
225 eprintln!("❌ Error: API key is required for this command.");
226 eprintln!(
227 " Please set WALLHAVEN_API_KEY environment variable or add api_key to config."
228 );
229 eprintln!(" Example: export WALLHAVEN_API_KEY=\"your_api_key_here\"");
230 std::process::exit(1);
231 }
232 let mut headers = reqwest::header::HeaderMap::new();
234 headers.insert(
235 reqwest::header::CONTENT_TYPE,
236 reqwest::header::HeaderValue::from_static("application/json"),
237 );
238 headers.insert(
239 reqwest::header::ACCEPT,
240 reqwest::header::HeaderValue::from_static("application/json"),
241 );
242 if let Some(k) = api_key {
243 let header_api_value =
244 reqwest::header::HeaderValue::from_str(&k).context("Invalid API key format")?;
245 headers.insert("X-API-KEY", header_api_value);
246 }
247
248 let client = reqwest::ClientBuilder::new()
249 .default_headers(headers)
250 .timeout(std::time::Duration::from_secs(rust_paper.config.timeout))
251 .build()
252 .context("Unable to create http client")?;
253
254 Ok(Self {
255 http_client: client,
256 commands,
257 rust_paper,
258 })
259 }
260
261 pub async fn execute(&mut self) -> Result<String, WallhavenClientError> {
262 let resp = match &self.commands {
263 Command::Search(s) => {
264 let res = self.request(s.to_url(BASE_URL)).await?;
265
266 if let Ok(r) = serde_json::from_str::<ErrorResponse>(&res) {
268 return Err(WallhavenClientError::RequestError(r.error));
269 }
270
271 let searchresp: SearchResponse = serde_json::from_str(&res)
273 .map_err(|e| WallhavenClientError::DecodeError(e.to_string()))?;
274 if s.download {
275 println!(" Found {} wallpaper(s)...", searchresp.data.len());
276 let max_concurrent = self.rust_paper.config.max_concurrent_downloads as usize;
277 let m = MultiProgress::new();
278 let save_location = self.rust_paper.config.save_location.clone();
279 let integrity = self.rust_paper.config.integrity;
280 let client = self.http_client.clone();
281 let mut tasks = stream::iter(searchresp.data.iter())
282 .map(|w| {
283 let save_loc = save_location.clone();
284 let client = client.clone();
285 let mp = m.clone();
286 async move {
287 let res = crate::helper::download_with_progress(
288 &w.path,
289 &w.id,
290 &save_loc,
291 &client,
292 integrity,
293 true,
294 Some(mp),
295 )
296 .await;
297 (w, res)
298 }
299 })
300 .buffer_unordered(max_concurrent);
301
302 let mut lock_updates = Vec::new();
303 while let Some((w, result)) = tasks.next().await {
304 match result {
305 Ok(dl_res) => {
306 let _ = m.println(format!(
307 " ✓ Downloaded {} - {}",
308 w.id, dl_res.file_path
309 ));
310 lock_updates.push((w.id.clone(), dl_res.file_path, dl_res.sha256));
311 }
312 Err(e) => {
313 let _ =
314 m.println(format!(" ✗ Failed to download {}: {}", w.id, e));
315 }
316 }
317 }
318
319 if !lock_updates.is_empty() {
321 if let Err(e) = crate::helper::update_wallpapers_list_and_lock(
323 lock_updates,
324 &mut self.rust_paper,
325 )
326 .await
327 {
328 eprintln!(" ⚠ Failed to update lock file: {}", e);
329 }
330 }
331 String::from("\n ✅ Download complete!")
332 } else {
333 format_search_results(&searchresp)
334 }
335 }
336 Command::TagInfo(t) => {
337 let res = self.request(t.to_url(BASE_URL)).await?;
338
339 if let Ok(r) = serde_json::from_str::<ErrorResponse>(&res) {
340 return Err(WallhavenClientError::RequestError(r.error));
341 }
342
343 let taginfo: TagResponse = serde_json::from_str(&res)
344 .map_err(|e| WallhavenClientError::DecodeError(e.to_string()))?;
345
346 format_tag_info(&taginfo.data)
347 }
348 Command::UserSettings(us) => {
349 let res = self.request(us.to_url(BASE_URL)).await?;
350
351 if let Ok(r) = serde_json::from_str::<ErrorResponse>(&res) {
352 return Err(WallhavenClientError::RequestError(r.error));
353 }
354
355 let usersettings: UserSettingsResponse = serde_json::from_str(&res)
356 .map_err(|e| WallhavenClientError::DecodeError(e.to_string()))?;
357
358 format_user_settings(&usersettings.data)
359 }
360 Command::UserCollections(uc) => {
361 let res = self.request(uc.to_url(BASE_URL)).await?;
362
363 if let Ok(r) = serde_json::from_str::<ErrorResponse>(&res) {
364 return Err(WallhavenClientError::RequestError(r.error));
365 }
366
367 let usercollections: UserCollectionsResponse = serde_json::from_str(&res)
368 .map_err(|e| WallhavenClientError::DecodeError(e.to_string()))?;
369
370 format_user_collections(&usercollections.data)
371 }
372 _ => String::new(),
373 };
374
375 Ok(resp)
376 }
377
378 pub async fn request(&self, url: String) -> Result<String, WallhavenClientError> {
379 let max_retry = self.rust_paper.config.retry_count;
380 for retry_count in 0..max_retry {
381 let send_result = self.http_client.get(&url).send().await;
382 match send_result {
383 Ok(response) => match response.text().await {
384 Ok(body) => return Ok(body),
385 Err(e) if retry_count + 1 < max_retry => {
386 let delay = 2_u64.pow(retry_count);
387 eprintln!(
388 " Error reading response body (attempt {} of {}): {}. Retrying in {}s...",
389 retry_count + 1,
390 max_retry,
391 e,
392 delay
393 );
394 sleep(Duration::from_secs(delay)).await;
395 continue;
396 }
397 Err(e) => {
398 return Err(WallhavenClientError::DecodeError(e.to_string()));
399 }
400 },
401 Err(e) if retry_count + 1 < max_retry => {
402 let delay = 2_u64.pow(retry_count);
403 eprintln!(
404 " Error fetching content (attempt {} of {}): {}. Retrying in {}s...",
405 retry_count + 1,
406 max_retry,
407 e,
408 delay
409 );
410 sleep(Duration::from_secs(delay)).await;
411 }
412 Err(e) => {
413 return Err(WallhavenClientError::RequestError(e.to_string()));
414 }
415 }
416 }
417 unreachable!()
418 }
419
420 pub async fn download_image(
421 &self,
422 url: &str,
423 path: &std::path::PathBuf,
424 ) -> Result<(), WallhavenClientError> {
425 let res = self
427 .http_client
428 .get(url)
429 .send()
430 .await
431 .map_err(|e| WallhavenClientError::RequestError(e.to_string()))?;
432
433 let total_size = res
435 .content_length()
436 .ok_or(format!("Failed to get content length from '{}'", &url))
437 .map_err(|e| WallhavenClientError::RequestError(e))?;
438
439 let pb = ProgressBar::new(total_size);
441 let style = ProgressStyle::with_template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})")
442 .unwrap()
443 .progress_chars("#>-");
444 pb.set_style(style);
445 pb.set_message(format!("Downloading {}", url));
446
447 let file_path = std::path::Path::new(path);
449 let mut file = OpenOptions::new()
450 .write(true)
451 .create(true)
452 .truncate(true)
453 .open(file_path)
454 .await
455 .map_err(|e| {
456 WallhavenClientError::WriteError(format!(
457 "Failed to create file - {}",
458 e.to_string()
459 ))
460 })?;
461
462 let mut downloaded: u64 = 0;
464 let mut stream = res.bytes_stream();
465
466 while let Some(item) = stream.next().await {
467 let chunk = item.or(Err(WallhavenClientError::RequestError(format!(
468 "Error while downloading file"
469 ))))?;
470
471 file.write_all(&chunk)
472 .map_err(|e| {
473 WallhavenClientError::WriteError(format!(
474 "Error while writing to file - {}",
475 e.to_string()
476 ))
477 })
478 .await?;
479
480 let new = u64::min(downloaded + (chunk.len() as u64), total_size);
481 downloaded = new;
482 pb.set_position(new);
483 }
484
485 pb.finish_with_message(format!("Downloaded {}", url));
486
487 Ok(())
488 }
489
490 pub async fn download_image_with_hash(
492 &self,
493 url: &str,
494 path: &std::path::PathBuf,
495 ) -> Result<String, WallhavenClientError> {
496 use sha2::{Digest, Sha256};
497 let res = self
498 .http_client
499 .get(url)
500 .send()
501 .await
502 .map_err(|e| WallhavenClientError::RequestError(e.to_string()))?;
503 let total_size = res
505 .content_length()
506 .ok_or(format!("Failed to get content length from '{}'", &url))
507 .map_err(|e| WallhavenClientError::RequestError(e))?;
508 let pb = ProgressBar::new(total_size);
510 let style = ProgressStyle::with_template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})")
511 .unwrap()
512 .progress_chars("#>-");
513 pb.set_style(style);
514 pb.set_message(format!("Downloading {}", url));
515 let file_path = std::path::Path::new(path);
516 let mut file = OpenOptions::new()
517 .write(true)
518 .create(true)
519 .truncate(true)
520 .open(file_path)
521 .await
522 .map_err(|e| {
523 WallhavenClientError::WriteError(format!(
524 "Failed to create file - {}",
525 e.to_string()
526 ))
527 })?;
528 let mut hasher = Sha256::new();
529 let mut downloaded: u64 = 0;
530 let mut stream = res.bytes_stream();
531 while let Some(item) = stream.next().await {
532 let chunk = item.or(Err(WallhavenClientError::RequestError(format!(
533 "Error while downloading file"
534 ))))?;
535 hasher.update(&chunk);
536 file.write_all(&chunk)
537 .map_err(|e| {
538 WallhavenClientError::WriteError(format!(
539 "Error while writing to file - {}",
540 e.to_string()
541 ))
542 })
543 .await?;
544 let new = u64::min(downloaded + (chunk.len() as u64), total_size);
545 downloaded = new;
546 pb.set_position(new);
547 }
548 pb.finish_with_message(format!("Downloaded {}", url));
549 let hash = format!("{:x}", hasher.finalize());
550 Ok(hash)
551 }
552}
553
554fn format_tag_info(tag: &Tag) -> String {
556 let mut output = String::new();
557 output.push_str(" Tag Information:\n");
558 output.push_str(" ────────────────\n");
559 output.push_str(&format!(" ID: {}\n", tag.id));
560 output.push_str(&format!(" Name: {}\n", tag.name));
561 if !tag.alias.is_empty() {
562 output.push_str(&format!(" Alias: {}\n", tag.alias));
563 }
564 output.push_str(&format!(
565 " Category: {} (ID: {})\n",
566 tag.category, tag.category_id
567 ));
568 output.push_str(&format!(" Purity: {}\n", tag.purity));
569 output.push_str(&format!(" Created: {}\n", tag.created_at));
570 output
571}
572
573fn format_user_settings(settings: &UserSettings) -> String {
575 let mut output = String::new();
576 output.push_str(" Your Wallhaven Settings:\n");
577 output.push_str(" ────────────────────────\n");
578 output.push_str(&format!(" Thumbnail Size: {}\n", settings.thumb_size));
579 output.push_str(&format!(" Per Page: {}\n", settings.per_page));
580 output.push_str(&format!(" Purity: {}\n", settings.purity.join(", ")));
581 output.push_str(&format!(
582 " Categories: {}\n",
583 settings.categories.join(", ")
584 ));
585 if !settings.resolutions.is_empty() && settings.resolutions[0] != "" {
586 output.push_str(&format!(
587 " Resolutions: {}\n",
588 settings.resolutions.join(", ")
589 ));
590 }
591 if !settings.aspect_ratios.is_empty() && settings.aspect_ratios[0] != "" {
592 output.push_str(&format!(
593 " Aspect Ratios: {}\n",
594 settings.aspect_ratios.join(", ")
595 ));
596 }
597 output.push_str(&format!(" Toplist Range: {}\n", settings.toplist_range));
598 if !settings.tag_blacklist.is_empty() && settings.tag_blacklist[0] != "" {
599 output.push_str(&format!(
600 " Tag Blacklist: {}\n",
601 settings.tag_blacklist.join(", ")
602 ));
603 }
604 if !settings.user_blacklist.is_empty() && settings.user_blacklist[0] != "" {
605 output.push_str(&format!(
606 " User Blacklist: {}\n",
607 settings.user_blacklist.join(", ")
608 ));
609 }
610 output
611}
612
613fn format_user_collections(collections: &[UserCollections]) -> String {
615 let mut output = String::new();
616 if collections.is_empty() {
617 output.push_str(" No collections found.\n");
618 return output;
619 }
620 output.push_str(&format!(" Collections ({} total):\n", collections.len()));
621 output.push_str(" ────────────────────────\n\n");
622 for collection in collections {
623 output.push_str(&format!(" 📁 {}\n", collection.label));
624 output.push_str(&format!(" ID: {}\n", collection.id));
625 output.push_str(&format!(" Wallpapers: {}\n", collection.count));
626 output.push_str(&format!(" Views: {}\n", collection.views));
627 output.push_str(&format!(
628 " Visibility: {}\n",
629 if collection.public == 1 {
630 "Public"
631 } else {
632 "Private"
633 }
634 ));
635 output.push_str("\n");
636 }
637 output
638}
639
640fn format_search_results(search_resp: &SearchResponse) -> String {
642 let mut output = String::new();
643 if search_resp.data.is_empty() {
644 output.push_str(" No wallpapers found matching your search criteria.\n");
645 return output;
646 }
647 output.push_str(&format!(" Search Results:\n"));
648 output.push_str(" ───────────────\n");
649 output.push_str(&format!(
650 " Found: {} wallpaper(s)\n",
651 search_resp.meta.total
652 ));
653 output.push_str(&format!(
654 " Page: {} of {}\n",
655 search_resp.meta.current_page, search_resp.meta.last_page
656 ));
657 output.push_str(&format!(" Per Page: {}\n", search_resp.meta.per_page));
658 if let Some(ref seed) = search_resp.meta.seed {
659 output.push_str(&format!(" Seed: {}\n", seed));
660 }
661 output.push_str("\n");
662 for (idx, wallpaper) in search_resp.data.iter().enumerate() {
664 output.push_str(&format!(
665 " {}. 🖼️ {} ({})\n",
666 idx + 1,
667 wallpaper.id,
668 wallpaper.resolution
669 ));
670 output.push_str(&format!(" URL: {}\n", wallpaper.url));
671 output.push_str(&format!(
672 " Category: {} | Purity: {}\n",
673 wallpaper.category, wallpaper.purity
674 ));
675 output.push_str(&format!(
676 " Size: {:.2} MB | Type: {}\n",
677 wallpaper.file_size as f64 / 1_048_576.0,
678 wallpaper.file_type.replace("image/", "")
679 ));
680 output.push_str(&format!(
681 " Views: {} | Favorites: {}\n",
682 wallpaper.views, wallpaper.favorites
683 ));
684 if !wallpaper.colors.is_empty() {
685 output.push_str(&format!(" Colors: {}\n", wallpaper.colors.join(", ")));
686 }
687 output.push_str(&format!(" Download: {}\n", wallpaper.path));
688 output.push_str("\n");
689 }
690
691 if search_resp.meta.current_page < search_resp.meta.last_page {
693 output.push_str(&format!(
694 " 💡 Tip: Use --page {} to see more results\n",
695 search_resp.meta.current_page + 1
696 ));
697 }
698
699 output
700}