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}