libpixiv 0.2.5

pixiv.net API client library
Documentation
use reqwest_middleware::{
    reqwest::{
        header::{ACCEPT_LANGUAGE, USER_AGENT},
        ClientBuilder, Response, StatusCode,
    },
    ClientWithMiddleware,
};
use serde::Deserialize;
use std::error::Error;
use std::{collections::HashMap, sync::Arc};

use crate::{
    tokens::{Session, SessionManager},
    PixivAppError,
};

#[derive(Debug, Clone)]
pub struct PixivAppClient {
    /// bearer token
    pub(crate) session: Arc<SessionManager>,
    pub(crate) http_client: ClientWithMiddleware,
    pub(crate) host: String,
    pub(crate) platform: params::Platform,
}

pub mod params {
    use serde::{Deserialize, Serialize};
    use strum::Display;

    /// Platform
    #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
    #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Display, Clone)]
    pub enum Platform {
        #[strum(to_string = "ios")]
        IOS,
        #[strum(to_string = "android")]
        Android,
    }

    /// Visibility for content
    #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
    #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Display, Clone)]
    pub enum Visibility {
        /// Visible for everyone (given no other filters apply)
        #[strum(to_string = "public")]
        Public,
        /// Only visible by logged in user or other "close" groups if any
        #[strum(to_string = "private")]
        Private,
    }

    /// Sort mode when search for content
    ///
    /// Some api routes only work with date related modes when
    /// the user has no premium subscription.
    #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
    #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Display, Clone)]
    pub enum SortMode {
        /// date, oldest to newest
        #[strum(to_string = "date_asc")]
        DateAscending,
        /// date, newest to oldest
        #[strum(to_string = "date_desc")]
        DateDescending,
        /// popularity, most to least popular
        #[strum(to_string = "popular_desc")]
        PopularDescending,
    }

    /// Seach mode for illustration search.
    #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
    #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Display, Clone)]
    pub enum SearchMode {
        /// requires match by part of the queried tags
        #[strum(to_string = "partial_match_for_tags")]
        PartialMatchForTags,
        /// only matches exact tags
        #[strum(to_string = "exact_match_for_tags")]
        ExactMatchForTags,
        /// searches in title and caption of the content
        #[strum(to_string = "title_and_caption")]
        TitleAndCaption,
    }

    /// Period for ranking search
    #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
    #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Display, Clone)]
    pub enum RankingMode {
        /// Ranking by last day
        #[strum(to_string = "day")]
        Day,
        /// Ranking by last week
        #[strum(to_string = "week")]
        Week,
        /// Ranking by last month
        #[strum(to_string = "month")]
        Month,
    }
}

/// The client structure for easy API access.
///
/// This struct provides bindings to the (known) pixiv API.
///
/// # Examples
///
/// This is the basic usage:
/// ```
/// # use libpixiv::client::PixivAppClient;
/// # async fn f() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = PixivAppClient::new("some_refresh_token".into());
/// client.illust_details(25308802).await;
/// # Ok(())
/// # }
/// ```
impl PixivAppClient {
    /// Create a new client using a refresh token
    pub fn new(token: String) -> Self {
        Self::_new(Arc::new(SessionManager::new(token)))
    }

    /// Restore an existing session
    pub fn restore(session: Session) -> Self {
        Self::_new(Arc::new(SessionManager::restore(session)))
    }

    fn _new(sessionman: Arc<SessionManager>) -> Self {
        Self {
            session: sessionman.clone(),
            http_client: reqwest_middleware::ClientBuilder::new(
                ClientBuilder::new()
                    .default_headers(
                        (&HashMap::from([
                            ("app-os".to_string(), "ios".to_string()),
                            ("app-os-version".to_string(), "12.2".to_string()),
                            ("app-version".to_string(), "7.6.2".to_string()),
                            (
                                USER_AGENT.to_string(),
                                "PixivIOSApp/7.6.2 (iOS 12.2; iPhone9,1)".to_string(),
                            ),
                        ]))
                            .try_into()
                            .unwrap(),
                    )
                    .build()
                    .unwrap(),
            )
            .with_arc(sessionman.clone())
            .build(),
            host: String::from("https://app-api.pixiv.net"),
            platform: params::Platform::IOS,
        }
    }

    /// Returns the session data. This instance gets detached from the internal session management,
    /// so external session changes are only possible during initialization.
    pub async fn session(&self) -> Session {
        self.session.session.read().await.clone()
    }

    /// Process a request built in another function and return the appropriate type
    pub(crate) async fn process_request<T: for<'de> Deserialize<'de>>(
        &self,
        reqb: reqwest_middleware::RequestBuilder,
    ) -> Result<T, Box<dyn Error + Send + Sync>> {
        Ok(reqb
            .header(ACCEPT_LANGUAGE, "en-US")
            .send()
            .await
            .map_or_else(
                |e| Err::<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 {
                if cfg!(test) {
                    eprintln!("{}", r);
                }
                let d = &mut serde_json::Deserializer::from_str(&r);
                let r = serde_path_to_error::deserialize(d);
                r
            })
            .map_err(Box::new)?
            .await?)
    }
}

#[cfg(test)]
pub mod tests {
    use once_cell::sync::Lazy;

    use super::*;
    use tokio::test;

    pub static USER: &str = "Aio";
    pub static USER_ID: u32 = 25308802;
    pub static ILLUST_ID: u32 = 132610892;
    pub static ILLUST_SERIES_ID: u32 = 144416706;
    pub static SERIES_ID: u32 = 280609;
    pub static _STUB: Lazy<Arc<()>> = Lazy::new(|| Arc::new(env_logger::init()));

    pub fn get_client() -> PixivAppClient {
        let _ = _STUB.clone();
        let client = PixivAppClient::new(env!("PIXIV_REFRESH_TOKEN").to_string());
        client
    }

    #[test]
    async fn test_bad_client() {
        let client = PixivAppClient::new("".to_string());
        assert!(client.illust_details(ILLUST_ID).await.is_err());
    }

    #[test]
    async fn test_session_restore() {
        let client = get_client();
        assert!(client.illust_details(ILLUST_ID).await.is_ok());
        let client2 = PixivAppClient::restore(client.session().await);
        assert!(client2.illust_details(ILLUST_ID).await.is_ok());
    }

    #[test]
    async fn test_bad_session_restore() {
        let _ = get_client(); // logging init
        let client = PixivAppClient::restore(
            serde_json::from_str(&format!(
                r#"{{
            "access_token":"",
            "refresh_token":"{}",
            "expiry":"2026-05-22T02:42:52.554084912+02:00"}}"#,
                env!("PIXIV_REFRESH_TOKEN")
            ))
            .unwrap(),
        );
        assert!(client.illust_details(ILLUST_ID).await.is_ok());
    }
}