booru_rs/client/
rule34.rs

1//! Rule34 API client implementation.
2
3use super::{Client, ClientBuilder, shared_client};
4use crate::autocomplete::{Autocomplete, TagSuggestion};
5use crate::error::{BooruError, Result};
6use crate::model::rule34::*;
7use serde::Deserialize;
8
9/// Client for interacting with the Rule34 API.
10///
11/// Rule34 has no tag limit for queries.
12///
13/// # Authentication
14///
15/// Rule34 **requires API credentials** for API access. You can obtain your
16/// API key and user ID from your [Rule34 account settings](https://rule34.xxx/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::rule34::{Rule34Client, Rule34Rating};
22/// use booru_rs::client::Client;
23///
24/// # async fn example() -> booru_rs::error::Result<()> {
25/// let posts = Rule34Client::builder()
26///     .set_credentials("your_api_key", "your_user_id")
27///     .tag("cat_ears")?
28///     .rating(Rule34Rating::Safe)
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/// # Content Warning
42///
43/// Rule34 is an adult (NSFW) image board. Content is not filtered by default.
44/// Use rating filters appropriately.
45///
46/// [`ClientBuilder::set_credentials`]: super::ClientBuilder::set_credentials
47/// [`BooruError::Unauthorized`]: crate::error::BooruError::Unauthorized
48#[derive(Debug)]
49pub struct Rule34Client(ClientBuilder<Self>);
50
51impl From<ClientBuilder<Self>> for Rule34Client {
52    fn from(value: ClientBuilder<Self>) -> Self {
53        Self(value)
54    }
55}
56
57impl Client for Rule34Client {
58    type Post = Rule34Post;
59    type Rating = Rule34Rating;
60
61    const URL: &'static str = "https://api.rule34.xxx";
62    const SORT: &'static str = "sort:";
63    const MAX_TAGS: Option<usize> = None;
64
65    /// Retrieves a single post by its unique ID.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`BooruError::PostNotFound`] if no post exists with the given ID.
70    /// Returns [`BooruError::Unauthorized`] if API credentials are missing or invalid.
71    /// Returns other errors if the request fails or the response cannot be parsed.
72    async fn get_by_id(&self, id: u32) -> Result<Self::Post> {
73        let builder = &self.0;
74        let url = &builder.url;
75
76        let mut query = vec![
77            ("page", "dapi".to_string()),
78            ("s", "post".to_string()),
79            ("q", "index".to_string()),
80            ("id", id.to_string()),
81            ("json", "1".to_string()),
82        ];
83
84        // Add API credentials if provided
85        if let (Some(key), Some(user)) = (&builder.key, &builder.user) {
86            query.push(("api_key", key.clone()));
87            query.push(("user_id", user.clone()));
88        }
89
90        let response = builder
91            .client
92            .get(format!("{url}/index.php"))
93            .query(&query)
94            .send()
95            .await?;
96
97        // Check for authentication errors (some APIs may return 401)
98        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
99            return Err(BooruError::Unauthorized(
100                "Rule34 requires API credentials. Use set_credentials(api_key, user_id)".into(),
101            ));
102        }
103
104        // Rule34 API quirk: returns HTTP 200 OK with error message in body instead of 401
105        // Example: "Missing authentication. Go to api.rule34.xxx for more information"
106        let text = response.text().await?;
107        if text.contains("Missing authentication") {
108            return Err(BooruError::Unauthorized(
109                "Rule34 requires API credentials. Use set_credentials(api_key, user_id)".into(),
110            ));
111        }
112
113        let posts: Vec<Rule34Post> = serde_json::from_str(&text)?;
114        posts.into_iter().next().ok_or(BooruError::PostNotFound(id))
115    }
116
117    /// Retrieves posts matching the configured query.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`BooruError::Unauthorized`] if API credentials are missing or invalid.
122    /// Returns other errors if the request fails or if the response cannot be parsed.
123    async fn get(&self) -> Result<Vec<Self::Post>> {
124        let builder = &self.0;
125        let url = &builder.url;
126        let tag_string = builder.tags.join(" ");
127
128        let mut query = vec![
129            ("page", "dapi".to_string()),
130            ("s", "post".to_string()),
131            ("q", "index".to_string()),
132            ("pid", builder.page.to_string()),
133            ("limit", builder.limit.to_string()),
134            ("tags", tag_string),
135            ("json", "1".to_string()),
136        ];
137
138        // Add API credentials if provided
139        if let (Some(key), Some(user)) = (&builder.key, &builder.user) {
140            query.push(("api_key", key.clone()));
141            query.push(("user_id", user.clone()));
142        }
143
144        let response = builder
145            .client
146            .get(format!("{url}/index.php"))
147            .query(&query)
148            .send()
149            .await?;
150
151        // Check for authentication errors (some APIs may return 401)
152        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
153            return Err(BooruError::Unauthorized(
154                "Rule34 requires API credentials. Use set_credentials(api_key, user_id)".into(),
155            ));
156        }
157
158        // Rule34 API quirk: returns HTTP 200 OK with error message in body instead of 401
159        // Example: "Missing authentication. Go to api.rule34.xxx for more information"
160        let text = response.text().await?;
161        if text.contains("Missing authentication") {
162            return Err(BooruError::Unauthorized(
163                "Rule34 requires API credentials. Use set_credentials(api_key, user_id)".into(),
164            ));
165        }
166
167        // Handle empty response (no results)
168        if text.is_empty() || text == "[]" {
169            return Ok(Vec::new());
170        }
171
172        let posts: Vec<Rule34Post> = serde_json::from_str(&text)?;
173        Ok(posts)
174    }
175}
176
177/// Internal response type for Rule34 autocomplete.
178#[derive(Debug, Deserialize)]
179struct Rule34AutocompleteItem {
180    /// The tag name.
181    value: String,
182    /// Display label (includes post count).
183    label: String,
184}
185
186impl Autocomplete for Rule34Client {
187    async fn autocomplete(query: &str, _limit: u32) -> Result<Vec<TagSuggestion>> {
188        let client = shared_client();
189        // Rule34 autocomplete is on api.rule34.xxx, not the main URL
190        let url = "https://api.rule34.xxx/autocomplete.php";
191
192        let response = client.get(url).query(&[("q", query)]).send().await?;
193
194        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
195            return Err(BooruError::Unauthorized(
196                "Rule34 autocomplete request failed".into(),
197            ));
198        }
199
200        let items: Vec<Rule34AutocompleteItem> = response.json().await?;
201
202        Ok(items
203            .into_iter()
204            .map(|item| TagSuggestion {
205                name: item.value,
206                label: item.label.clone(),
207                post_count: parse_post_count_from_label(&item.label),
208                category: None,
209            })
210            .collect())
211    }
212}
213
214/// Parses post count from a label like "tag_name (12345)".
215fn parse_post_count_from_label(label: &str) -> Option<u32> {
216    let start = label.rfind('(')?;
217    let end = label.rfind(')')?;
218    if start < end {
219        label[start + 1..end].parse().ok()
220    } else {
221        None
222    }
223}