use chrono::Local;
use reqwest::{
header::{HeaderName, AUTHORIZATION, CONTENT_TYPE, USER_AGENT},
IntoUrl, Method, RequestBuilder, StatusCode,
};
use serde::Deserialize;
use serde_json::Value;
use std::error::Error;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::PixivAppError;
pub struct PixivAppClient {
pub(crate) access_token: Arc<Mutex<String>>,
pub(crate) refresh_token: Arc<Mutex<String>>,
pub(crate) http_client: reqwest::Client,
pub(crate) host: String,
pub(crate) platform: params::Platform,
}
pub mod params {
use std::fmt::{Display, Formatter, Result};
pub enum Platform {
IOS,
Android,
}
impl Display for Platform {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
write!(
f,
"{}",
match self {
Self::IOS => "ios",
Self::Android => "android",
}
)
}
}
pub enum Visibility {
Public,
Private,
}
impl Display for Visibility {
fn fmt(&self, f: &mut Formatter) -> Result {
write!(
f,
"{}",
match self {
Self::Public => String::from("public"),
Self::Private => String::from("private"),
}
)
}
}
pub enum SortMode {
DateAscending,
DateDescending,
PopularDescending,
}
impl Display for SortMode {
fn fmt(&self, f: &mut Formatter) -> Result {
write!(
f,
"{}",
match self {
Self::DateAscending => String::from("date_asc"),
Self::DateDescending => String::from("date_desc"),
Self::PopularDescending => String::from("popular_desc"),
}
)
}
}
pub enum SearchMode {
PartialMatchForTags,
ExactMatchForTags,
TitleAndCaption,
}
impl Display for SearchMode {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
write!(
f,
"{}",
match self {
Self::PartialMatchForTags => String::from("partial_match_for_tags"),
Self::ExactMatchForTags => String::from("exact_match_for_tags"),
Self::TitleAndCaption => String::from("title_and_caption"),
}
)
}
}
pub enum RankingMode {
Day,
Week,
Month,
}
impl Display for RankingMode {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
write!(
f,
"{}",
match self {
Self::Day => String::from("day"),
Self::Week => String::from("week"),
Self::Month => String::from("month"),
}
)
}
}
}
impl PixivAppClient {
pub fn new(token: String) -> Self {
Self {
access_token: Arc::new(Mutex::new(String::new())),
refresh_token: Arc::new(Mutex::new(token)),
http_client: reqwest::Client::new(),
host: String::from("https://app-api.pixiv.net"),
platform: params::Platform::IOS,
}
}
fn md5(input: &str) -> String {
let result = md5::compute(input);
format!("{:02x}", result)
}
pub async fn refresh_token(&mut self) {
let time = Local::now().format("%y-%m-%dT%H:%m:%s+00:00");
let time_str = format!("{}", time);
let cloned_refresh_token = Arc::clone(&self.refresh_token);
let cloned_refresh_token_str = &cloned_refresh_token.lock().await;
let client_id = "MOBrBDS8blbauoSck0ZfDbtuzpyT";
let client_secret = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj";
let hash_input = format!(
"{}{}\n",
&time_str, "28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c"
);
let hash = PixivAppClient::md5(hash_input.as_str());
let req = self.http_client
.post("https://oauth.secure.pixiv.net/auth/token")
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(USER_AGENT, "PixivIOSApp/7.6.2 (iOS 12.2; iPhone9,1)")
.header(HeaderName::from_lowercase(b"x-client-time").unwrap(), &time_str)
.header(HeaderName::from_lowercase(b"x-client-hash").unwrap(), hash)
.body(format!("grant_type=refresh_token&client_id={}&refresh_token={}&client_secret={}&get_secure_url=1", client_id, cloned_refresh_token_str, client_secret))
.build()
.expect("failed to build login request");
let r = match self.http_client.execute(req).await {
Ok(r) => r.text().await.unwrap(),
Err(_e) => return,
};
if cfg!(debug_assertions) {
eprintln!("{}", r);
}
let d: Value = serde_json::from_str(&r).unwrap();
assert!(!d["response"]["access_token"].is_null());
assert!(!d["response"]["refresh_token"].is_null());
self.access_token = Arc::new(Mutex::new(String::from(
d["response"]["access_token"].as_str().unwrap(),
)));
self.refresh_token = Arc::new(Mutex::new(String::from(
d["response"]["refresh_token"].as_str().unwrap(),
)));
}
pub(crate) async fn auth_request<U: IntoUrl>(&self, method: Method, url: U) -> RequestBuilder {
self.http_client
.request(method, url)
.header(
AUTHORIZATION,
format!("Bearer {}", self.access_token.lock().await),
)
.header(HeaderName::from_lowercase(b"app-os").unwrap(), "ios")
.header(
HeaderName::from_lowercase(b"app-os-version").unwrap(),
"12.2",
)
.header(HeaderName::from_lowercase(b"app-version").unwrap(), "7.6.2")
.header(USER_AGENT, "PixivIOSApp/7.6.2 (iOS 12.2; iPhone9,1)")
}
pub(crate) async fn process_request<T: for<'de> Deserialize<'de>>(
&self,
reqb: RequestBuilder,
) -> Result<T, Box<dyn Error + Send + Sync>> {
Ok(reqb
.send()
.await
.map_or_else(
|e| Err::<reqwest::Response, Box<dyn Error + Send + Sync>>(Box::new(e)),
|r| match r.status() {
x if StatusCode::is_success(&x) || StatusCode::is_redirection(&x) => Ok(r),
x if StatusCode::is_client_error(&x) => Err(Box::new(match x {
StatusCode::BAD_REQUEST => PixivAppError::RequestFailed,
StatusCode::UNAUTHORIZED => PixivAppError::MissingLogin,
StatusCode::NOT_FOUND => PixivAppError::TargetNotFound,
StatusCode::TOO_MANY_REQUESTS => PixivAppError::RateLimitReached,
s => PixivAppError::UnhandledStatus(s),
})),
x => Err(Box::new(PixivAppError::UnhandledStatus(x))),
},
)?
.text()
.await
.map(|r| async move {
let text = r;
if cfg!(debug_assertions) {
eprintln!("{}", text);
}
let d = &mut serde_json::Deserializer::from_str(&text);
let r = serde_path_to_error::deserialize(d);
r
})
.map_err(Box::new)?
.await?)
}
}
#[cfg(test)]
pub mod tests {
use super::*;
pub static USER: &str = "Aio";
pub static USER_ID: u32 = 25308802;
pub static ILLUST_ID: u32 = 132610892;
pub async fn get_client() -> PixivAppClient {
let mut client = PixivAppClient::new(env!("PIXIV_REFRESH_TOKEN").to_string());
client.refresh_token().await;
client
}
}