booru_rs/client/
gelbooru.rs

1//! Gelbooru API client implementation.
2
3use super::{Client, ClientBuilder, shared_client};
4use crate::autocomplete::{Autocomplete, TagSuggestion};
5use crate::error::{BooruError, Result};
6use crate::model::gelbooru::*;
7use serde::Deserialize;
8
9/// Client for interacting with the Gelbooru API.
10///
11/// Gelbooru has no tag limit for queries.
12///
13/// # Authentication
14///
15/// Gelbooru **requires API credentials** for API access. You can obtain your
16/// API key and user ID from your [Gelbooru account settings](https://gelbooru.com/index.php?page=account&s=options).
17///
18/// Use [`ClientBuilder::set_credentials`] to provide your API key and user ID:
19///
20/// ```no_run
21/// use booru_rs::gelbooru::{GelbooruClient, GelbooruRating};
22/// use booru_rs::client::Client;
23///
24/// # async fn example() -> booru_rs::error::Result<()> {
25/// let posts = GelbooruClient::builder()
26///     .set_credentials("your_api_key", "your_user_id")
27///     .tag("cat_ears")?
28///     .rating(GelbooruRating::General)
29///     .limit(10)
30///     .build()
31///     .get()
32///     .await?;
33///
34/// println!("Found {} posts", posts.len());
35/// # Ok(())
36/// # }
37/// ```
38///
39/// Without credentials, requests will fail with [`BooruError::Unauthorized`].
40///
41/// [`ClientBuilder::set_credentials`]: super::ClientBuilder::set_credentials
42/// [`BooruError::Unauthorized`]: crate::error::BooruError::Unauthorized
43#[derive(Debug)]
44pub struct GelbooruClient(ClientBuilder<Self>);
45
46impl From<ClientBuilder<Self>> for GelbooruClient {
47    fn from(value: ClientBuilder<Self>) -> Self {
48        Self(value)
49    }
50}
51
52impl Client for GelbooruClient {
53    type Post = GelbooruPost;
54    type Rating = GelbooruRating;
55
56    const URL: &'static str = "https://gelbooru.com";
57    const SORT: &'static str = "sort:";
58    const MAX_TAGS: Option<usize> = None;
59
60    /// Retrieves a single post by its unique ID.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`BooruError::PostNotFound`] if no post exists with the given ID.
65    /// Returns [`BooruError::Unauthorized`] if API credentials are missing or invalid.
66    /// Returns other errors if the request fails or the response cannot be parsed.
67    async fn get_by_id(&self, id: u32) -> Result<Self::Post> {
68        let builder = &self.0;
69        let url = &builder.url;
70
71        let mut query = vec![
72            ("page", "dapi".to_string()),
73            ("s", "post".to_string()),
74            ("q", "index".to_string()),
75            ("id", id.to_string()),
76            ("json", "1".to_string()),
77        ];
78
79        // Add API credentials if provided
80        if let (Some(key), Some(user)) = (&builder.key, &builder.user) {
81            query.push(("api_key", key.clone()));
82            query.push(("user_id", user.clone()));
83        }
84
85        let response = builder
86            .client
87            .get(format!("{url}/index.php"))
88            .query(&query)
89            .send()
90            .await?;
91
92        // Check for authentication errors
93        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
94            return Err(BooruError::Unauthorized(
95                "Gelbooru requires API credentials. Use set_credentials(api_key, user_id)".into(),
96            ));
97        }
98
99        let data = response.json::<GelbooruResponse>().await?;
100
101        data.posts
102            .into_iter()
103            .next()
104            .ok_or(BooruError::PostNotFound(id))
105    }
106
107    /// Retrieves posts matching the configured query.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`BooruError::Unauthorized`] if API credentials are missing or invalid.
112    /// Returns other errors if the request fails or if the response cannot be parsed.
113    async fn get(&self) -> Result<Vec<Self::Post>> {
114        let builder = &self.0;
115        let url = &builder.url;
116        let tag_string = builder.tags.join(" ");
117
118        let mut query = vec![
119            ("page", "dapi".to_string()),
120            ("s", "post".to_string()),
121            ("q", "index".to_string()),
122            ("pid", builder.page.to_string()),
123            ("limit", builder.limit.to_string()),
124            ("tags", tag_string),
125            ("json", "1".to_string()),
126        ];
127
128        // Add API credentials if provided
129        if let (Some(key), Some(user)) = (&builder.key, &builder.user) {
130            query.push(("api_key", key.clone()));
131            query.push(("user_id", user.clone()));
132        }
133
134        let response = builder
135            .client
136            .get(format!("{url}/index.php"))
137            .query(&query)
138            .send()
139            .await?;
140
141        // Check for authentication errors
142        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
143            return Err(BooruError::Unauthorized(
144                "Gelbooru requires API credentials. Use set_credentials(api_key, user_id)".into(),
145            ));
146        }
147
148        let data = response.json::<GelbooruResponse>().await?;
149
150        Ok(data.posts)
151    }
152}
153
154/// Internal response type for Gelbooru autocomplete.
155#[derive(Debug, Deserialize)]
156struct GelbooruAutocompleteItem {
157    /// The tag name.
158    value: String,
159    /// Display label (includes post count).
160    label: String,
161    /// Tag category (optional).
162    #[serde(default)]
163    category: Option<String>,
164    /// Number of posts with this tag (optional).
165    #[serde(default)]
166    post_count: Option<u32>,
167}
168
169impl Autocomplete for GelbooruClient {
170    async fn autocomplete(query: &str, limit: u32) -> Result<Vec<TagSuggestion>> {
171        let client = shared_client();
172        let url = format!("{}/index.php", Self::URL);
173
174        let response = client
175            .get(&url)
176            .query(&[
177                ("page", "autocomplete2"),
178                ("term", query),
179                ("type", "tag_query"),
180                ("limit", &limit.to_string()),
181            ])
182            .send()
183            .await?;
184
185        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
186            return Err(BooruError::Unauthorized(
187                "Gelbooru requires API credentials for some endpoints".into(),
188            ));
189        }
190
191        let items: Vec<GelbooruAutocompleteItem> = response.json().await?;
192
193        Ok(items
194            .into_iter()
195            .map(|item| {
196                // Try to parse post count from label if not provided directly
197                let post_count = item
198                    .post_count
199                    .or_else(|| parse_post_count_from_label(&item.label));
200
201                // Convert category string to numeric ID if present
202                let category = item.category.as_deref().and_then(parse_category);
203
204                TagSuggestion {
205                    name: item.value,
206                    label: item.label,
207                    post_count,
208                    category,
209                }
210            })
211            .collect())
212    }
213}
214
215/// Parses category string to numeric ID.
216fn parse_category(cat: &str) -> Option<u8> {
217    match cat.to_lowercase().as_str() {
218        "general" | "tag" => Some(0),
219        "artist" => Some(1),
220        "copyright" | "series" => Some(3),
221        "character" => Some(4),
222        "meta" | "metadata" => Some(5),
223        _ => cat.parse().ok(),
224    }
225}
226
227/// Parses post count from a label like "tag_name (12345)".
228fn parse_post_count_from_label(label: &str) -> Option<u32> {
229    let start = label.rfind('(')?;
230    let end = label.rfind(')')?;
231    if start < end {
232        label[start + 1..end].parse().ok()
233    } else {
234        None
235    }
236}