tosho_rbean/
lib.rs

1#![warn(missing_docs, clippy::empty_docs, rustdoc::broken_intra_doc_links)]
2#![doc = include_str!("../README.md")]
3
4use futures_util::TryStreamExt;
5use std::collections::HashMap;
6use tokio::io::{self, AsyncWriteExt};
7use tosho_common::{
8    ToshoAuthError, ToshoClientError, ToshoError, ToshoResult, bail_on_error, make_error,
9    parse_json_response,
10};
11
12use crate::models::UserAccount;
13pub use config::*;
14use constants::{API_HOST, BASE_API, IMAGE_HOST, TOKEN_AUTH};
15use models::{
16    ChapterDetailsResponse, ChapterListResponse, ChapterPageDetailsResponse, HomeResponse, Manga,
17    MangaListResponse, Publisher, ReadingListItem, SortOption,
18};
19use serde_json::json;
20
21pub mod config;
22pub mod constants;
23pub mod models;
24
25const PATTERN: [u8; 1] = [174];
26
27/// Main client for interacting with the 小豆 (Red Bean) API
28///
29/// # Examples
30/// ```rust,no_run
31/// use tosho_rbean::{RBClient, RBConfig, RBPlatform};
32///
33/// #[tokio::main]
34/// async fn main() {
35///     let config = RBConfig::new("123", "abcxyz", RBPlatform::Android);
36///     let mut client = RBClient::new(config).unwrap();
37///     // Refresh token
38///     client.refresh_token().await.unwrap();
39///     let user = client.get_user().await.unwrap();
40///     println!("{:?}", user);
41/// }
42/// ```
43#[derive(Clone, Debug)]
44pub struct RBClient {
45    inner: reqwest::Client,
46    config: RBConfig,
47    constants: &'static crate::constants::Constants,
48    token: String,
49    expiry_at: Option<i64>,
50}
51
52impl RBClient {
53    /// Create a new client instance.
54    ///
55    /// # Arguments
56    /// * `config` - The configuration to use for the client.
57    pub fn new(config: RBConfig) -> ToshoResult<Self> {
58        Self::make_client(config, None)
59    }
60
61    /// Attach a proxy to the client.
62    ///
63    /// This will clone the client and return a new client with the proxy attached.
64    ///
65    /// # Arguments
66    /// * `proxy` - The proxy to attach to the client
67    pub fn with_proxy(&self, proxy: reqwest::Proxy) -> ToshoResult<Self> {
68        Self::make_client(self.config.clone(), Some(proxy))
69    }
70
71    /// Internal function to make the client
72    fn make_client(config: RBConfig, proxy: Option<reqwest::Proxy>) -> ToshoResult<Self> {
73        let constants = crate::constants::get_constants(config.platform() as u8);
74        let mut headers = reqwest::header::HeaderMap::new();
75
76        headers.insert(
77            reqwest::header::USER_AGENT,
78            reqwest::header::HeaderValue::from_static(constants.ua),
79        );
80        headers.insert(
81            reqwest::header::HOST,
82            reqwest::header::HeaderValue::from_static(API_HOST),
83        );
84        headers.insert(
85            "public",
86            reqwest::header::HeaderValue::from_static(constants.public),
87        );
88        headers.insert(
89            "x-user-token",
90            config.token().parse().map_err(|_| {
91                ToshoClientError::HeaderParseError(format!("x-user-token for {}", config.token()))
92            })?,
93        );
94
95        let client = reqwest::Client::builder()
96            .http2_adaptive_window(true)
97            .use_rustls_tls()
98            .default_headers(headers);
99
100        let client = match proxy {
101            Some(proxy) => client
102                .proxy(proxy)
103                .build()
104                .map_err(ToshoClientError::BuildError),
105            None => client.build().map_err(ToshoClientError::BuildError),
106        }?;
107
108        Ok(Self {
109            inner: client,
110            config: config.clone(),
111            constants,
112            token: config.token().to_string(),
113            expiry_at: None,
114        })
115    }
116
117    /// Set the expiry at of the token manually
118    ///
119    /// Not really recommended to use.
120    pub fn set_expiry_at(&mut self, expiry_at: Option<i64>) {
121        self.expiry_at = expiry_at;
122    }
123
124    /// Refresh the token of the client.
125    ///
126    /// The following function will be called on each request to ensure the token is always valid.
127    ///
128    /// The first request will always be a token refresh, and subsequent requests will only refresh
129    /// if the token is expired.
130    pub async fn refresh_token(&mut self) -> ToshoResult<()> {
131        // If the expiry time is set and it's not expired, return early
132        if let Some(expiry_at) = self.expiry_at
133            && expiry_at > chrono::Utc::now().timestamp()
134        {
135            return Ok(());
136        }
137
138        let json_data = json!({
139            "grantType": "refresh_token",
140            "refreshToken": self.config.refresh_token(),
141        });
142
143        let client = reqwest::Client::builder()
144            .http2_adaptive_window(true)
145            .use_rustls_tls()
146            .build()
147            .map_err(ToshoClientError::BuildError)?;
148        let request = client
149            .post("https://securetoken.googleapis.com/v1/token")
150            .header(reqwest::header::USER_AGENT, self.constants.image_ua)
151            .query(&[("key", TOKEN_AUTH.to_string())])
152            .json(&json_data)
153            .send()
154            .await?;
155
156        let response = request
157            .json::<crate::models::accounts::google::SecureTokenResponse>()
158            .await?;
159
160        self.token.clone_from(&response.access_token().to_string());
161        self.config.set_token(response.access_token());
162        let expiry_in = response.expires_in().parse::<i64>().map_err(|e| {
163            make_error!(
164                "Failed to parse expiry time: {}, error: {}",
165                response.expires_in(),
166                e
167            )
168        })?;
169        // Set the expiry time to 3 seconds before the actual expiry time
170        self.expiry_at = Some(chrono::Utc::now().timestamp() + expiry_in - 3);
171
172        Ok(())
173    }
174
175    /// Get the current token of the client.
176    pub fn get_token(&self) -> &str {
177        &self.token
178    }
179
180    /// Get the expiry time of the token.
181    pub fn get_expiry_at(&self) -> Option<i64> {
182        self.expiry_at
183    }
184
185    // <-- Common Helper
186
187    /// Request to the API with the given method and url.
188    async fn request<T>(
189        &mut self,
190        method: reqwest::Method,
191        url: &str,
192        json_body: Option<HashMap<String, String>>,
193    ) -> ToshoResult<T>
194    where
195        T: serde::de::DeserializeOwned,
196    {
197        self.refresh_token().await?;
198
199        let endpoint = format!("{}{}", BASE_API, url);
200
201        let request = match json_body {
202            Some(json_body) => self.inner.request(method, endpoint).json(&json_body),
203            None => self.inner.request(method, endpoint),
204        };
205
206        let response = request.send().await?;
207
208        if response.status().is_success() {
209            let json_de = parse_json_response::<T>(response).await?;
210            Ok(json_de)
211        } else {
212            Err(ToshoError::from(response.status()))
213        }
214    }
215
216    // --> Common Helper
217
218    // <-- UserApiInterface.kt
219
220    /// Get the current user account information.
221    pub async fn get_user(&mut self) -> ToshoResult<UserAccount> {
222        self.request(reqwest::Method::GET, "/user/v0", None).await
223    }
224
225    /// Get the current user reading list.
226    pub async fn get_reading_list(&mut self) -> ToshoResult<Vec<ReadingListItem>> {
227        self.request(reqwest::Method::GET, "/user/reading_list/v0", None)
228            .await
229    }
230
231    // --> UserApiInterface.kt
232
233    // <-- MangaApiInterface.kt
234
235    /// Get the manga information for a specific manga.
236    ///
237    /// # Arguments
238    /// * `uuid` - The UUID of the manga.
239    pub async fn get_manga(&mut self, uuid: impl AsRef<str>) -> ToshoResult<Manga> {
240        self.request(
241            reqwest::Method::GET,
242            &format!("/manga/{}/v0", uuid.as_ref()),
243            None,
244        )
245        .await
246    }
247
248    /// Get the manga filters for searching manga.
249    pub async fn get_manga_filters(&mut self) -> ToshoResult<Manga> {
250        self.request(reqwest::Method::GET, "/manga/filters/v0", None)
251            .await
252    }
253
254    /// Get chapter list for a specific manga.
255    ///
256    /// # Arguments
257    /// * `uuid` - The UUID of the manga.
258    pub async fn get_chapter_list(
259        &mut self,
260        uuid: impl AsRef<str>,
261    ) -> ToshoResult<ChapterListResponse> {
262        self.request(
263            reqwest::Method::GET,
264            &format!(
265                "/mangas/{}/chapters/v4?order=asc&count=9999&offset=0",
266                uuid.as_ref()
267            ),
268            None,
269        )
270        .await
271    }
272
273    /// Get the chapter details for a specific chapter.
274    ///
275    /// # Arguments
276    /// * `uuid` - The UUID of the chapter.
277    pub async fn get_chapter(
278        &mut self,
279        uuid: impl AsRef<str>,
280    ) -> ToshoResult<ChapterDetailsResponse> {
281        self.request(
282            reqwest::Method::GET,
283            &format!("/chapters/{}/v2", uuid.as_ref()),
284            None,
285        )
286        .await
287    }
288
289    /// Get the chapter viewer for a specific chapter.
290    ///
291    /// # Arguments
292    /// * `uuid` - The UUID of the chapter.
293    pub async fn get_chapter_viewer(
294        &mut self,
295        uuid: impl AsRef<str>,
296    ) -> ToshoResult<ChapterPageDetailsResponse> {
297        self.request(
298            reqwest::Method::GET,
299            &format!("/chapters/{}/pages/v1", uuid.as_ref()),
300            None,
301        )
302        .await
303    }
304
305    /// Do a search for a manga.
306    ///
307    /// # Arguments
308    /// * `query` - The query to search for.
309    /// * `offset` - The offset of the search result, default to `0`
310    /// * `count` - The count of the search result, default to `999`
311    /// * `sort` - The sort option of the search result, default to [`SortOption::Alphabetical`]
312    pub async fn search(
313        &mut self,
314        query: impl AsRef<str>,
315        offset: Option<u32>,
316        count: Option<u32>,
317        sort: Option<SortOption>,
318    ) -> ToshoResult<MangaListResponse> {
319        let offset = offset.unwrap_or(0);
320        let count = count.unwrap_or(999);
321        let sort = sort.unwrap_or(SortOption::Alphabetical);
322
323        let query_param = format!(
324            "sort={}&offset={}&count={}&tags=&search_string={}&publisher_slug=",
325            sort,
326            offset,
327            count,
328            query.as_ref()
329        );
330
331        self.request(
332            reqwest::Method::GET,
333            &format!("/mangas/v1?{query_param}"),
334            None,
335        )
336        .await
337    }
338
339    /// Get the home page information.
340    pub async fn get_home_page(&mut self) -> ToshoResult<HomeResponse> {
341        self.request(reqwest::Method::GET, "/home/v0", None).await
342    }
343
344    /// Get specific publisher information by their slug.
345    ///
346    /// # Arguments
347    /// * `slug` - The slug of the publisher.
348    pub async fn get_publisher(&mut self, slug: impl AsRef<str>) -> ToshoResult<Publisher> {
349        self.request(
350            reqwest::Method::GET,
351            &format!("/publisher/slug/{}/v0", slug.as_ref()),
352            None,
353        )
354        .await
355    }
356
357    // --> Image
358
359    /// Modify the URL to get the high resolution image URL.
360    ///
361    /// # Arguments
362    /// * `url` - The URL to modify.
363    pub fn modify_url_for_highres(url: impl AsRef<str>) -> ToshoResult<String> {
364        let url = url.as_ref();
365        let mut parsed_url = url
366            .parse::<reqwest::Url>()
367            .map_err(|e| make_error!("Failed to parse URL: {}, error: {}", url, e))?;
368
369        // Formatted: https://{hostname}/{uuid}/{img_res}.[jpg/webp]
370        let path = parsed_url.path();
371        let mut path_split = path.split('/').collect::<Vec<&str>>();
372        let last_part = match path_split.pop() {
373            Some(last_part) => last_part,
374            None => {
375                bail_on_error!("Invalid URL path: {}", path);
376            }
377        };
378
379        let filename = last_part.split_once('.');
380        let (_, extension) = match filename {
381            Some((filename, extension)) => (filename, extension),
382            None => {
383                bail_on_error!(
384                    "Invalid filename: {}, expected something like 0000.jpg",
385                    last_part
386                );
387            }
388        };
389
390        let hi_res = format!("2000.{extension}");
391        let new_path = format!("{}/{}", path_split.join("/"), hi_res);
392        parsed_url.set_path(&new_path);
393
394        Ok(parsed_url.to_string())
395    }
396
397    /// Stream download the image from the given URL.
398    ///
399    /// The URL can be obtained from [`RBClient::get_chapter_viewer`].
400    ///
401    /// # Parameters
402    /// * `url` - The URL to download the image from.
403    /// * `writer` - The writer to write the image to.
404    pub async fn stream_download(
405        &self,
406        url: impl AsRef<str>,
407        mut writer: impl io::AsyncWrite + Unpin,
408    ) -> ToshoResult<()> {
409        let res = self
410            .inner
411            .get(url.as_ref())
412            .query(&[("drm", "1")])
413            .headers({
414                let mut headers = reqwest::header::HeaderMap::new();
415                headers.insert(
416                    reqwest::header::USER_AGENT,
417                    reqwest::header::HeaderValue::from_static(self.constants.image_ua),
418                );
419                headers.insert(
420                    reqwest::header::HOST,
421                    reqwest::header::HeaderValue::from_static(IMAGE_HOST),
422                );
423                headers
424            })
425            .send()
426            .await?;
427
428        if !res.status().is_success() {
429            Err(ToshoError::from(res.status()))
430        } else {
431            // Check if we need to decrypt
432            let x_drm = res.headers().get(crate::constants::X_DRM_HEADER);
433            let is_drm = match x_drm {
434                Some(x_drm) => x_drm == "true",
435                None => false,
436            };
437
438            let mut stream = res.bytes_stream();
439            while let Some(item) = stream.try_next().await? {
440                let dedrmed = if is_drm {
441                    decrypt_image(&item)
442                } else {
443                    item.to_vec()
444                };
445
446                writer.write_all(&dedrmed).await?;
447                writer.flush().await?;
448            }
449
450            Ok(())
451        }
452    }
453
454    /// Try checking if the "hidden" high resolution image is available.
455    ///
456    /// Give the URL of any image that is requested from the API.
457    pub async fn test_high_res(&self, url: impl AsRef<str>) -> ToshoResult<bool> {
458        // Do head request to check if the high res image is available
459        let url_mod = Self::modify_url_for_highres(url)?;
460
461        let res = self
462            .inner
463            .head(url_mod)
464            .query(&[("drm", "1")])
465            .headers({
466                let mut headers = reqwest::header::HeaderMap::new();
467                headers.insert(
468                    reqwest::header::USER_AGENT,
469                    reqwest::header::HeaderValue::from_static(self.constants.image_ua),
470                );
471                headers.insert(
472                    reqwest::header::HOST,
473                    reqwest::header::HeaderValue::from_static(IMAGE_HOST),
474                );
475                headers
476            })
477            .send()
478            .await?;
479
480        let success = res.status().is_success();
481        let mimetype = res.headers().get(reqwest::header::CONTENT_TYPE);
482        // Good mimetype are either image/jpeg or image/webp
483        let good_mimetype = match mimetype {
484            Some(mimetype) => mimetype == "image/jpeg" || mimetype == "image/webp",
485            None => false,
486        };
487
488        Ok(success && good_mimetype)
489    }
490
491    // <-- Image
492
493    // --> MangaApiInterface.kt
494
495    /// Authenticate the given email and password with RB.
496    ///
497    /// # Arguments
498    /// * `email` - The email to authenticate with.
499    /// * `password` - The password to authenticate with.
500    /// * `platform` - The platform type.
501    pub async fn login(
502        email: impl AsRef<str>,
503        password: impl AsRef<str>,
504        platform: RBPlatform,
505    ) -> ToshoResult<RBLoginResponse> {
506        let constants = crate::constants::get_constants(platform as u8);
507
508        let mut headers = reqwest::header::HeaderMap::new();
509        headers.insert(
510            reqwest::header::USER_AGENT,
511            reqwest::header::HeaderValue::from_static(constants.image_ua),
512        );
513
514        let client_type = match platform {
515            RBPlatform::Android => Some("CLIENT_TYPE_ANDROID"),
516            RBPlatform::Apple => Some("CLIENT_TYPE_IOS"),
517            _ => None,
518        };
519
520        let email = email.as_ref();
521        let password = password.as_ref();
522
523        let mut json_data = json!({
524            "email": email,
525            "password": password,
526            "returnSecureToken": true,
527        });
528        if let Some(client_type) = client_type {
529            json_data["clientType"] = client_type.into();
530        }
531
532        let client = reqwest::Client::builder()
533            .http2_adaptive_window(true)
534            .use_rustls_tls()
535            .default_headers(headers)
536            .build()
537            .map_err(ToshoClientError::BuildError)?;
538
539        let key_param = &[("key", TOKEN_AUTH.to_string())];
540
541        // Step 1: Verify password
542        let request = client
543            .post("https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword")
544            .query(key_param)
545            .json(&json_data)
546            .send()
547            .await?;
548
549        let verify_resp = request
550            .json::<crate::models::accounts::google::IdentityToolkitVerifyPasswordResponse>()
551            .await?;
552
553        // Step 2: Get account info
554        let json_data = json!({
555            "idToken": verify_resp.id_token(),
556        });
557
558        let request = client
559            .post("https://www.googleapis.com/identitytoolkit/v3/relyingparty/getAccountInfo")
560            .query(key_param)
561            .json(&json_data)
562            .send()
563            .await?;
564
565        let acc_info_resp = request
566            .json::<crate::models::accounts::google::IdentityToolkitAccountInfoResponse>()
567            .await?;
568
569        // Step 2.5: Find user
570        let goog_user = acc_info_resp
571            .users()
572            .iter()
573            .find(|&user| user.local_id() == verify_resp.local_id())
574            .ok_or(ToshoAuthError::UnknownSession)?;
575
576        // Step 3: Refresh token
577        let json_data = json!({
578            "grantType": "refresh_token",
579            "refreshToken": verify_resp.refresh_token(),
580        });
581
582        let request = client
583            .post("https://securetoken.googleapis.com/v1/token")
584            .query(key_param)
585            .json(&json_data)
586            .send()
587            .await?;
588
589        let secure_token_resp = request
590            .json::<crate::models::accounts::google::SecureTokenResponse>()
591            .await?;
592
593        let expires_in = secure_token_resp.expires_in().parse::<i64>().map_err(|e| {
594            make_error!(
595                "Failed to parse expiry time: {}, error: {}",
596                secure_token_resp.expires_in(),
597                e
598            )
599        })?;
600        let expiry_at = chrono::Utc::now().timestamp() + expires_in - 3;
601
602        // Step 4: Auth with 小豆
603        let request = client
604            .get(format!("{}/user/v0", BASE_API))
605            .headers({
606                let mut headers = reqwest::header::HeaderMap::new();
607                headers.insert(
608                    reqwest::header::USER_AGENT,
609                    reqwest::header::HeaderValue::from_static(constants.ua),
610                );
611                headers.insert(
612                    "public",
613                    reqwest::header::HeaderValue::from_static(constants.public),
614                );
615                headers.insert(
616                    "x-user-token",
617                    reqwest::header::HeaderValue::from_str(secure_token_resp.access_token())
618                        .map_err(|_| {
619                            ToshoClientError::HeaderParseError(format!(
620                                "x-user-token for {}",
621                                secure_token_resp.access_token()
622                            ))
623                        })?,
624                );
625                headers
626            })
627            .send()
628            .await?;
629
630        let user_resp = request.json::<UserAccount>().await?;
631
632        Ok(RBLoginResponse {
633            token: secure_token_resp.access_token().to_string(),
634            refresh_token: secure_token_resp.refresh_token().to_string(),
635            platform,
636            user: user_resp,
637            google_account: goog_user.clone(),
638            expiry: expiry_at,
639        })
640    }
641}
642
643/// Represents the login response from the 小豆 (Red Bean) API
644///
645/// The following struct is returned when you use [`RBClient::login`] method.
646///
647/// This struct wraps some other struct that can be useful for config building yourself.
648#[derive(Debug, Clone)]
649pub struct RBLoginResponse {
650    /// The token of the account
651    pub token: String,
652    /// The refresh token of the account
653    pub refresh_token: String,
654    /// The platform of the account
655    pub platform: RBPlatform,
656    /// Detailed account information
657    pub user: UserAccount,
658    /// Detailed google account information
659    pub google_account: crate::models::accounts::google::IdentityToolkitAccountInfo,
660    /// Expiry time of the token
661    pub expiry: i64,
662}
663
664/// A simple image decryptor for the 小豆 (Red Bean) API
665///
666/// # Arguments
667/// * `data` - The image data to decrypt
668pub fn decrypt_image(data: &[u8]) -> Vec<u8> {
669    let mut internal: Vec<u8> = Vec::with_capacity(data.len());
670    internal.extend_from_slice(data);
671    internal.iter_mut().for_each(|v| *v ^= PATTERN[0]);
672    internal
673}