Skip to main content

libpixiv/
client.rs

1use reqwest_middleware::{
2    reqwest::{
3        header::{ACCEPT_LANGUAGE, USER_AGENT},
4        ClientBuilder, Response, StatusCode,
5    },
6    ClientWithMiddleware,
7};
8use serde::Deserialize;
9use std::error::Error;
10use std::{collections::HashMap, sync::Arc};
11
12use crate::{
13    tokens::{Session, SessionManager},
14    PixivAppError,
15};
16
17#[derive(Debug, Clone)]
18pub struct PixivAppClient {
19    /// bearer token
20    pub(crate) session: Arc<SessionManager>,
21    pub(crate) http_client: ClientWithMiddleware,
22    pub(crate) host: String,
23    pub(crate) platform: params::Platform,
24}
25
26pub mod params {
27    use serde::{Deserialize, Serialize};
28    use strum::Display;
29
30    /// Platform
31    #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
32    #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Display, Clone)]
33    pub enum Platform {
34        #[strum(to_string = "ios")]
35        IOS,
36        #[strum(to_string = "android")]
37        Android,
38    }
39
40    /// Visibility for content
41    #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
42    #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Display, Clone)]
43    pub enum Visibility {
44        /// Visible for everyone (given no other filters apply)
45        #[strum(to_string = "public")]
46        Public,
47        /// Only visible by logged in user or other "close" groups if any
48        #[strum(to_string = "private")]
49        Private,
50    }
51
52    /// Sort mode when search for content
53    ///
54    /// Some api routes only work with date related modes when
55    /// the user has no premium subscription.
56    #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
57    #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Display, Clone)]
58    pub enum SortMode {
59        /// date, oldest to newest
60        #[strum(to_string = "date_asc")]
61        DateAscending,
62        /// date, newest to oldest
63        #[strum(to_string = "date_desc")]
64        DateDescending,
65        /// popularity, most to least popular
66        #[strum(to_string = "popular_desc")]
67        PopularDescending,
68    }
69
70    /// Seach mode for illustration search.
71    #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
72    #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Display, Clone)]
73    pub enum SearchMode {
74        /// requires match by part of the queried tags
75        #[strum(to_string = "partial_match_for_tags")]
76        PartialMatchForTags,
77        /// only matches exact tags
78        #[strum(to_string = "exact_match_for_tags")]
79        ExactMatchForTags,
80        /// searches in title and caption of the content
81        #[strum(to_string = "title_and_caption")]
82        TitleAndCaption,
83    }
84
85    /// Period for ranking search
86    #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
87    #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Display, Clone)]
88    pub enum RankingMode {
89        /// Ranking by last day
90        #[strum(to_string = "day")]
91        Day,
92        /// Ranking by last week
93        #[strum(to_string = "week")]
94        Week,
95        /// Ranking by last month
96        #[strum(to_string = "month")]
97        Month,
98    }
99}
100
101/// The client structure for easy API access.
102///
103/// This struct provides bindings to the (known) pixiv API.
104///
105/// # Examples
106///
107/// This is the basic usage:
108/// ```
109/// # use libpixiv::client::PixivAppClient;
110/// # async fn f() -> Result<(), Box<dyn std::error::Error>> {
111/// let mut client = PixivAppClient::new("some_refresh_token".into());
112/// client.illust_details(25308802).await;
113/// # Ok(())
114/// # }
115/// ```
116impl PixivAppClient {
117    /// Create a new client using a refresh token
118    pub fn new(token: String) -> Self {
119        Self::_new(Arc::new(SessionManager::new(token)))
120    }
121
122    /// Restore an existing session
123    pub fn restore(session: Session) -> Self {
124        Self::_new(Arc::new(SessionManager::restore(session)))
125    }
126
127    fn _new(sessionman: Arc<SessionManager>) -> Self {
128        Self {
129            session: sessionman.clone(),
130            http_client: reqwest_middleware::ClientBuilder::new(
131                ClientBuilder::new()
132                    .default_headers(
133                        (&HashMap::from([
134                            ("app-os".to_string(), "ios".to_string()),
135                            ("app-os-version".to_string(), "12.2".to_string()),
136                            ("app-version".to_string(), "7.6.2".to_string()),
137                            (
138                                USER_AGENT.to_string(),
139                                "PixivIOSApp/7.6.2 (iOS 12.2; iPhone9,1)".to_string(),
140                            ),
141                        ]))
142                            .try_into()
143                            .unwrap(),
144                    )
145                    .build()
146                    .unwrap(),
147            )
148            .with_arc(sessionman.clone())
149            .build(),
150            host: String::from("https://app-api.pixiv.net"),
151            platform: params::Platform::IOS,
152        }
153    }
154
155    /// Returns the session data. This instance gets detached from the internal session management,
156    /// so external session changes are only possible during initialization.
157    pub async fn session(&self) -> Session {
158        self.session.session.read().await.clone()
159    }
160
161    /// Process a request built in another function and return the appropriate type
162    pub(crate) async fn process_request<T: for<'de> Deserialize<'de>>(
163        &self,
164        reqb: reqwest_middleware::RequestBuilder,
165    ) -> Result<T, Box<dyn Error + Send + Sync>> {
166        Ok(reqb
167            .header(ACCEPT_LANGUAGE, "en-US")
168            .send()
169            .await
170            .map_or_else(
171                |e| Err::<Response, Box<dyn Error + Send + Sync>>(Box::new(e)),
172                |r| match r.status() {
173                    x if StatusCode::is_success(&x) || StatusCode::is_redirection(&x) => Ok(r),
174                    x if StatusCode::is_client_error(&x) => Err(Box::new(match x {
175                        StatusCode::BAD_REQUEST => PixivAppError::RequestFailed,
176                        StatusCode::UNAUTHORIZED => PixivAppError::MissingLogin,
177                        StatusCode::NOT_FOUND => PixivAppError::TargetNotFound,
178                        StatusCode::TOO_MANY_REQUESTS => PixivAppError::RateLimitReached,
179                        s => PixivAppError::UnhandledStatus(s),
180                    })),
181                    x => Err(Box::new(PixivAppError::UnhandledStatus(x))),
182                },
183            )?
184            .text()
185            .await
186            .map(|r| async move {
187                if cfg!(test) {
188                    eprintln!("{}", r);
189                }
190                let d = &mut serde_json::Deserializer::from_str(&r);
191                let r = serde_path_to_error::deserialize(d);
192                r
193            })
194            .map_err(Box::new)?
195            .await?)
196    }
197}
198
199#[cfg(test)]
200pub mod tests {
201    use once_cell::sync::Lazy;
202
203    use super::*;
204    use tokio::test;
205
206    pub static USER: &str = "Aio";
207    pub static USER_ID: u32 = 25308802;
208    pub static ILLUST_ID: u32 = 132610892;
209    pub static ILLUST_SERIES_ID: u32 = 144416706;
210    pub static SERIES_ID: u32 = 280609;
211    pub static _STUB: Lazy<Arc<()>> = Lazy::new(|| Arc::new(env_logger::init()));
212
213    pub fn get_client() -> PixivAppClient {
214        let _ = _STUB.clone();
215        let client = PixivAppClient::new(env!("PIXIV_REFRESH_TOKEN").to_string());
216        client
217    }
218
219    #[test]
220    async fn test_bad_client() {
221        let client = PixivAppClient::new("".to_string());
222        assert!(client.illust_details(ILLUST_ID).await.is_err());
223    }
224
225    #[test]
226    async fn test_session_restore() {
227        let client = get_client();
228        assert!(client.illust_details(ILLUST_ID).await.is_ok());
229        let client2 = PixivAppClient::restore(client.session().await);
230        assert!(client2.illust_details(ILLUST_ID).await.is_ok());
231    }
232
233    #[test]
234    async fn test_bad_session_restore() {
235        let _ = get_client(); // logging init
236        let client = PixivAppClient::restore(
237            serde_json::from_str(&format!(
238                r#"{{
239            "access_token":"",
240            "refresh_token":"{}",
241            "expiry":"2026-05-22T02:42:52.554084912+02:00"}}"#,
242                env!("PIXIV_REFRESH_TOKEN")
243            ))
244            .unwrap(),
245        );
246        assert!(client.illust_details(ILLUST_ID).await.is_ok());
247    }
248}