booru_rs/client/
safebooru.rs

1//! Safebooru API client implementation.
2
3use super::{Client, ClientBuilder, shared_client};
4use crate::autocomplete::{Autocomplete, TagSuggestion};
5use crate::error::{BooruError, Result};
6use crate::model::safebooru::{SafebooruPost, SafebooruRating};
7
8use serde::Deserialize;
9
10/// Client for interacting with the Safebooru API.
11///
12/// Safebooru is a SFW-only booru with no tag limits.
13///
14/// # Example
15///
16/// ```no_run
17/// use booru_rs::safebooru::{SafebooruClient, SafebooruRating};
18/// use booru_rs::client::Client;
19///
20/// # async fn example() -> booru_rs::error::Result<()> {
21/// let posts = SafebooruClient::builder()
22///     .tag("cat_ears")?
23///     .rating(SafebooruRating::General)
24///     .limit(10)
25///     .build()
26///     .get()
27///     .await?;
28///
29/// println!("Found {} posts", posts.len());
30/// # Ok(())
31/// # }
32/// ```
33#[derive(Debug)]
34pub struct SafebooruClient(ClientBuilder<Self>);
35
36impl From<ClientBuilder<Self>> for SafebooruClient {
37    fn from(value: ClientBuilder<Self>) -> Self {
38        Self(value)
39    }
40}
41
42impl Client for SafebooruClient {
43    type Post = SafebooruPost;
44    type Rating = SafebooruRating;
45
46    const URL: &'static str = "https://safebooru.org";
47    const SORT: &'static str = "sort:";
48    const MAX_TAGS: Option<usize> = None;
49
50    /// Retrieves a single post by its unique ID.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`BooruError::PostNotFound`] if no post exists with the given ID.
55    /// Returns other errors if the request fails or the response cannot be parsed.
56    async fn get_by_id(&self, id: u32) -> Result<Self::Post> {
57        let builder = &self.0;
58        let url = &builder.url;
59
60        let response = builder
61            .client
62            .get(format!("{url}/index.php"))
63            .query(&[
64                ("page", "dapi"),
65                ("s", "post"),
66                ("q", "index"),
67                ("id", &id.to_string()),
68                ("json", "1"),
69            ])
70            .send()
71            .await?
72            .json::<Vec<SafebooruPost>>()
73            .await?;
74
75        response
76            .into_iter()
77            .next()
78            .ok_or(BooruError::PostNotFound(id))
79    }
80
81    /// Retrieves posts matching the configured query.
82    ///
83    /// # Errors
84    ///
85    /// Returns an error if the request fails or if the response cannot be parsed.
86    async fn get(&self) -> Result<Vec<Self::Post>> {
87        let builder = &self.0;
88        let url = &builder.url;
89        let tags = builder.tags.join(" ");
90
91        let response = builder
92            .client
93            .get(format!("{url}/index.php"))
94            .query(&[
95                ("page", "dapi"),
96                ("s", "post"),
97                ("q", "index"),
98                ("pid", &builder.page.to_string()),
99                ("limit", &builder.limit.to_string()),
100                ("tags", &tags),
101                ("json", "1"),
102            ])
103            .send()
104            .await?
105            .json::<Vec<SafebooruPost>>()
106            .await?;
107
108        Ok(response)
109    }
110}
111
112/// Safebooru autocomplete API response item.
113#[derive(Debug, Deserialize)]
114struct SafebooruAutocompleteItem {
115    value: String,
116    label: String,
117}
118
119impl Autocomplete for SafebooruClient {
120    /// Returns tag suggestions from Safebooru's autocomplete API.
121    ///
122    /// # Example
123    ///
124    /// ```no_run
125    /// use booru_rs::safebooru::SafebooruClient;
126    /// use booru_rs::autocomplete::Autocomplete;
127    ///
128    /// # async fn example() -> booru_rs::error::Result<()> {
129    /// let suggestions = SafebooruClient::autocomplete("land", 5).await?;
130    /// for tag in suggestions {
131    ///     println!("{}", tag.name);
132    /// }
133    /// # Ok(())
134    /// # }
135    /// ```
136    async fn autocomplete(query: &str, limit: u32) -> Result<Vec<TagSuggestion>> {
137        let response = shared_client()
138            .get(format!("{}/autocomplete.php", Self::URL))
139            .query(&[("q", query)])
140            .send()
141            .await?
142            .json::<Vec<SafebooruAutocompleteItem>>()
143            .await?;
144
145        // Safebooru includes post count in the label like "cat_ears (177448)"
146        // Parse it out if present
147        Ok(response
148            .into_iter()
149            .take(limit as usize)
150            .map(|item| {
151                let post_count = parse_post_count_from_label(&item.label);
152                TagSuggestion {
153                    name: item.value,
154                    label: item.label,
155                    post_count,
156                    category: None,
157                }
158            })
159            .collect())
160    }
161}
162
163/// Parses post count from a label like "cat_ears (177448)".
164fn parse_post_count_from_label(label: &str) -> Option<u32> {
165    let start = label.rfind('(')?;
166    let end = label.rfind(')')?;
167    if start < end {
168        label[start + 1..end].parse().ok()
169    } else {
170        None
171    }
172}