Skip to main content

rust_paper/
api.rs

1use crate::RustPaper;
2use anyhow::{Context, Error};
3use futures::stream::{self, StreamExt};
4
5pub const BASE_URL: &str = "https://wallhaven.cc/api/v1";
6
7// ------------------------------------------------------------
8// Api response types
9use 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, //Should be int,idk why its string even when api guide defines as int
53    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        /* Create http client */
233        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                // Check if we got bad status response and return it
267                if let Ok(r) = serde_json::from_str::<ErrorResponse>(&res) {
268                    return Err(WallhavenClientError::RequestError(r.error));
269                }
270
271                // Check if response has the structure as described in api guide
272                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                    // Update lock file...
320                    if !lock_updates.is_empty() {
321                        // Now `self` is free to be used here because it wasn't moved into the stream
322                        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        // Reqwest setup
426        let res = self
427            .http_client
428            .get(url)
429            .send()
430            .await
431            .map_err(|e| WallhavenClientError::RequestError(e.to_string()))?;
432
433        // Get information for bar
434        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        // Indicatif setup
440        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        // Create file path
448        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        // Write file
463        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    /// Download image with SHA256 hashing support
491    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        // Get information for bar
504        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        // Indicatif setup
509        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
554/// Format tag information for display
555fn 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
573/// Format user settings for display
574fn 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
613/// Format user collections for display
614fn 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
640/// Format search results for display
641fn 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    // Display each wallpaper
663    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    // Add pagination hint if there are more pages
692    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}