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 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 #[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 #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
42 #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Display, Clone)]
43 pub enum Visibility {
44 #[strum(to_string = "public")]
46 Public,
47 #[strum(to_string = "private")]
49 Private,
50 }
51
52 #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
57 #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Display, Clone)]
58 pub enum SortMode {
59 #[strum(to_string = "date_asc")]
61 DateAscending,
62 #[strum(to_string = "date_desc")]
64 DateDescending,
65 #[strum(to_string = "popular_desc")]
67 PopularDescending,
68 }
69
70 #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
72 #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Display, Clone)]
73 pub enum SearchMode {
74 #[strum(to_string = "partial_match_for_tags")]
76 PartialMatchForTags,
77 #[strum(to_string = "exact_match_for_tags")]
79 ExactMatchForTags,
80 #[strum(to_string = "title_and_caption")]
82 TitleAndCaption,
83 }
84
85 #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
87 #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Display, Clone)]
88 pub enum RankingMode {
89 #[strum(to_string = "day")]
91 Day,
92 #[strum(to_string = "week")]
94 Week,
95 #[strum(to_string = "month")]
97 Month,
98 }
99}
100
101impl PixivAppClient {
117 pub fn new(token: String) -> Self {
119 Self::_new(Arc::new(SessionManager::new(token)))
120 }
121
122 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 pub async fn session(&self) -> Session {
158 self.session.session.read().await.clone()
159 }
160
161 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(); 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}