tosho_amap/
lib.rs

1#![warn(missing_docs, clippy::empty_docs, rustdoc::broken_intra_doc_links)]
2#![doc = include_str!("../README.md")]
3
4use std::{collections::HashMap, sync::MutexGuard};
5
6use constants::{
7    API_HOST, APP_NAME, BASE_API, HEADER_NAMES, IMAGE_HOST, MASKED_LOGIN, get_constants,
8};
9use futures_util::TryStreamExt;
10use helper::ComicPurchase;
11use models::{
12    APIResult, AccountUserResponse, ComicDiscovery, ComicDiscoveryPaginatedResponse,
13    ComicSearchResponse, ComicStatus, StatusResult,
14};
15use reqwest_cookie_store::CookieStoreMutex;
16use sha2::{Digest, Sha256};
17use tokio::io::AsyncWriteExt;
18
19pub use config::*;
20use tosho_common::{
21    FailableResponse, ToshoAuthError, ToshoClientError, ToshoParseError, ToshoResult, make_error,
22    parse_json_response, parse_json_response_failable,
23};
24pub mod config;
25pub mod constants;
26pub mod helper;
27pub mod models;
28
29const SCREEN_INCH: f64 = 61.1918658356194;
30
31/// Main client for interacting with the AP AM
32///
33/// # Example
34/// ```rust,no_run
35/// use tosho_amap::{AMClient, AMConfig};
36///
37/// #[tokio::main]
38/// async fn main() {
39///     let config = AMConfig::new("123", "abcxyz", "xyz987abc");
40///     let client = AMClient::new(config).unwrap();
41///     let manga = client.get_comic(48000051).await.unwrap();
42///     println!("{:?}", manga);
43/// }
44/// ```
45#[derive(Clone)]
46pub struct AMClient {
47    inner: reqwest::Client,
48    config: AMConfig,
49    constants: &'static constants::Constants,
50    cookie_store: std::sync::Arc<CookieStoreMutex>,
51}
52
53impl AMClient {
54    /// Create a new client instance.
55    ///
56    /// # Parameters
57    /// * `config` - The configuration to use for the client.
58    pub fn new(config: AMConfig) -> ToshoResult<Self> {
59        Self::make_client(config, None)
60    }
61
62    /// Attach a proxy to the client.
63    ///
64    /// This will clone the client and return a new client with the proxy attached.
65    ///
66    /// # Arguments
67    /// * `proxy` - The proxy to attach to the client
68    pub fn with_proxy(&self, proxy: reqwest::Proxy) -> ToshoResult<Self> {
69        Self::make_client(self.config.clone(), Some(proxy))
70    }
71
72    fn make_client(config: AMConfig, proxy: Option<reqwest::Proxy>) -> ToshoResult<Self> {
73        let mut headers = reqwest::header::HeaderMap::new();
74        headers.insert(
75            reqwest::header::ACCEPT,
76            reqwest::header::HeaderValue::from_static("application/json"),
77        );
78        headers.insert(
79            reqwest::header::HOST,
80            reqwest::header::HeaderValue::from_static(API_HOST),
81        );
82        let constants = get_constants(1);
83        headers.insert(
84            reqwest::header::USER_AGENT,
85            reqwest::header::HeaderValue::from_static(&constants.ua),
86        );
87
88        let cookie_store = CookieStoreMutex::try_from(config.clone())?;
89        let cookie_store = std::sync::Arc::new(cookie_store);
90
91        let client = reqwest::Client::builder()
92            .http2_adaptive_window(true)
93            .use_rustls_tls()
94            .default_headers(headers)
95            .cookie_provider(std::sync::Arc::clone(&cookie_store));
96
97        let client = match proxy {
98            Some(proxy) => client
99                .proxy(proxy)
100                .build()
101                .map_err(ToshoClientError::BuildError),
102            None => client.build().map_err(ToshoClientError::BuildError),
103        }?;
104
105        Ok(Self {
106            inner: client,
107            config,
108            constants,
109            cookie_store,
110        })
111    }
112
113    /// Apply the JSON object with the default values.
114    fn apply_json_object(
115        &self,
116        json_obj: &mut HashMap<String, serde_json::Value>,
117    ) -> ToshoResult<()> {
118        json_with_common(json_obj, self.constants)
119    }
120
121    /// Get the underlying cookie store.
122    pub fn get_cookie_store(&self) -> MutexGuard<'_, reqwest_cookie_store::CookieStore> {
123        self.cookie_store.lock().unwrap()
124    }
125
126    async fn request<T>(
127        &self,
128        method: reqwest::Method,
129        endpoint: &str,
130        json: Option<HashMap<String, serde_json::Value>>,
131    ) -> ToshoResult<APIResult<T>>
132    where
133        T: serde::de::DeserializeOwned + std::clone::Clone,
134    {
135        let endpoint = format!("{}{}", BASE_API, endpoint);
136
137        let mut cloned_json = json.clone().unwrap_or_default();
138        self.apply_json_object(&mut cloned_json)?;
139
140        let headers = make_header(&self.config, self.constants)?;
141
142        let req = self
143            .inner
144            .request(method, &endpoint)
145            .headers(headers)
146            .json(&cloned_json)
147            .send()
148            .await?;
149
150        parse_json_response_failable::<APIResult<T>, BasicWrapStatus>(req).await
151    }
152
153    /// Get the account information or remainder.
154    ///
155    /// This request has data related to user point and more.
156    pub async fn get_remainder(&self) -> ToshoResult<models::IAPRemainder> {
157        let mut json_body = HashMap::new();
158        json_body.insert(
159            "i_token".to_string(),
160            serde_json::Value::String(self.config.token().to_string()),
161        );
162        json_body.insert(
163            "iap_product_version".to_string(),
164            serde_json::Value::Number(serde_json::Number::from(0_u32)),
165        );
166        json_body.insert("app_login".to_string(), serde_json::Value::Bool(true));
167
168        let results = self
169            .request::<models::IAPRemainder>(
170                reqwest::Method::POST,
171                "/iap/remainder.json",
172                Some(json_body),
173            )
174            .await?;
175
176        results
177            .result()
178            .body()
179            .ok_or_else(ToshoParseError::empty)
180            .cloned()
181    }
182
183    /// Get a single comic information by ID.
184    ///
185    /// # Arguments
186    /// * `id` - The ID of the comic.
187    pub async fn get_comic(&self, id: u64) -> ToshoResult<models::ComicInfoResponse> {
188        let mut json_body = HashMap::new();
189        json_body.insert(
190            "manga_sele_id".to_string(),
191            serde_json::Value::Number(serde_json::Number::from(id)),
192        );
193        json_body.insert(
194            "i_token".to_string(),
195            serde_json::Value::String(self.config.token().to_string()),
196        );
197        json_body.insert("app_login".to_string(), serde_json::Value::Bool(true));
198
199        let results = self
200            .request::<models::ComicInfoResponse>(
201                reqwest::Method::POST,
202                "/iap/comicCover.json",
203                Some(json_body),
204            )
205            .await?;
206
207        results
208            .result()
209            .body()
210            .ok_or_else(ToshoParseError::empty)
211            .cloned()
212    }
213
214    /// Get reader/viewer for an episode.
215    ///
216    /// # Arguments
217    /// * `comic_id` - The ID of the comic.
218    /// * `episode` - The episode being read.
219    pub async fn get_comic_viewer(
220        &self,
221        id: u64,
222        episode: &ComicPurchase,
223    ) -> ToshoResult<models::ComicReadResponse> {
224        let mut json_body = HashMap::new();
225        json_body.insert(
226            "manga_sele_id".to_string(),
227            serde_json::Value::Number(serde_json::Number::from(id)),
228        );
229        json_body.insert(
230            "story_no".to_string(),
231            serde_json::Value::Number(serde_json::Number::from(episode.id)),
232        );
233        if let Some(rental_term) = episode.rental_term.clone() {
234            json_body.insert(
235                "rental_term".to_string(),
236                serde_json::Value::String(rental_term),
237            );
238        }
239        json_body.insert(
240            "bonus".to_string(),
241            serde_json::Value::Number(serde_json::Number::from(episode.bonus)),
242        );
243        json_body.insert(
244            "product".to_string(),
245            serde_json::Value::Number(serde_json::Number::from(episode.purchased)),
246        );
247        json_body.insert(
248            "premium".to_string(),
249            serde_json::Value::Number(serde_json::Number::from(episode.premium)),
250        );
251        if let Some(point) = episode.point {
252            json_body.insert(
253                "point".to_string(),
254                serde_json::Value::Number(serde_json::Number::from(point)),
255            );
256        }
257        json_body.insert(
258            "is_free_daily".to_string(),
259            serde_json::Value::Bool(episode.is_free_daily),
260        );
261        json_body.insert(
262            "i_token".to_string(),
263            serde_json::Value::String(self.config.token().to_string()),
264        );
265        json_body.insert("app_login".to_string(), serde_json::Value::Bool(true));
266
267        let results = self
268            .request::<models::ComicReadResponse>(
269                reqwest::Method::POST,
270                "/iap/mangaDownload.json",
271                Some(json_body),
272            )
273            .await?;
274
275        results
276            .result()
277            .body()
278            .ok_or_else(ToshoParseError::empty)
279            .cloned()
280    }
281
282    /// Get the account for the current session.
283    pub async fn get_account(&self) -> ToshoResult<AccountUserResponse> {
284        let mut json_body = HashMap::new();
285        json_body.insert("mine".to_string(), serde_json::Value::Bool(true));
286
287        let results = self
288            .request::<AccountUserResponse>(
289                reqwest::Method::POST,
290                "/author/profile.json",
291                Some(json_body),
292            )
293            .await?;
294
295        results
296            .result()
297            .body()
298            .ok_or_else(ToshoParseError::empty)
299            .cloned()
300    }
301
302    /// Get account favorites.
303    pub async fn get_favorites(&self) -> ToshoResult<ComicDiscoveryPaginatedResponse> {
304        let results = self
305            .request::<ComicDiscoveryPaginatedResponse>(
306                reqwest::Method::POST,
307                "/mypage/favOfficialComicList.json",
308                None,
309            )
310            .await?;
311
312        results
313            .result()
314            .body()
315            .ok_or_else(ToshoParseError::empty)
316            .cloned()
317    }
318
319    /// Search for comics.
320    ///
321    /// # Arguments
322    /// * `query` - The query to search for.
323    /// * `page` - The page to search for. (default to 1)
324    /// * `limit` - The limit of results per page. (default to 30)
325    pub async fn search(
326        &self,
327        query: impl Into<String>,
328        status: Option<ComicStatus>,
329        tag_id: Option<u64>,
330        page: Option<u64>,
331        limit: Option<u64>,
332    ) -> ToshoResult<ComicSearchResponse> {
333        let mut json_body = HashMap::new();
334
335        let mut conditions = serde_json::Map::new();
336        conditions.insert(
337            "free_word".to_string(),
338            serde_json::Value::String(query.into()),
339        );
340        conditions.insert(
341            "tag_id".to_string(),
342            serde_json::Value::Number(serde_json::Number::from(tag_id.unwrap_or(0))),
343        );
344        if let Some(status) = status {
345            conditions.insert(
346                "complete".to_string(),
347                serde_json::Value::Number(serde_json::Number::from(status as i32)),
348            );
349        }
350        json_body.insert(
351            "conditions".to_string(),
352            serde_json::Value::Object(conditions),
353        );
354        json_body.insert(
355            "page".to_string(),
356            serde_json::Value::Number(serde_json::Number::from(page.unwrap_or(1))),
357        );
358        json_body.insert(
359            "limit".to_string(),
360            serde_json::Value::Number(serde_json::Number::from(limit.unwrap_or(30))),
361        );
362
363        let results = self
364            .request::<ComicSearchResponse>(
365                reqwest::Method::POST,
366                "/manga/official.json",
367                Some(json_body),
368            )
369            .await?;
370
371        results
372            .result()
373            .body()
374            .ok_or_else(ToshoParseError::empty)
375            .cloned()
376    }
377
378    /// Get home discovery.
379    pub async fn get_discovery(&self) -> ToshoResult<ComicDiscovery> {
380        let results = self
381            .request::<ComicDiscovery>(reqwest::Method::POST, "/manga/discover.json", None)
382            .await?;
383
384        results
385            .result()
386            .body()
387            .ok_or_else(ToshoParseError::empty)
388            .cloned()
389    }
390
391    /// Stream download the image from the given URL.
392    ///
393    /// # Arguments
394    /// * `url` - The URL of the image.
395    /// * `writer` - The writer to write the image to.
396    pub async fn stream_download(
397        &self,
398        url: impl AsRef<str>,
399        mut writer: impl tokio::io::AsyncWrite + Unpin,
400    ) -> ToshoResult<()> {
401        let mut headers = make_header(&self.config, self.constants)?;
402        headers.insert(
403            "Host",
404            reqwest::header::HeaderValue::from_static(IMAGE_HOST),
405        );
406        headers.insert(
407            "User-Agent",
408            reqwest::header::HeaderValue::from_static(&self.constants.image_ua),
409        );
410
411        let res = self.inner.get(url.as_ref()).headers(headers).send().await?;
412
413        // bail if not success
414        if !res.status().is_success() {
415            Err(tosho_common::ToshoError::from(res.status()))
416        } else {
417            let mut stream = res.bytes_stream();
418            while let Some(item) = stream.try_next().await? {
419                writer.write_all(&item).await?;
420                writer.flush().await?;
421            }
422
423            Ok(())
424        }
425    }
426
427    /// Perform a login request.
428    ///
429    /// # Arguments
430    /// * `email` - The email of the user.
431    /// * `password` - The password of the user.
432    pub async fn login(
433        email: impl Into<String>,
434        password: impl Into<String>,
435    ) -> ToshoResult<AMConfig> {
436        let cookie_store = CookieStoreMutex::default();
437        let cookie_store = std::sync::Arc::new(cookie_store);
438
439        let mut headers = reqwest::header::HeaderMap::new();
440        headers.insert(
441            reqwest::header::ACCEPT,
442            reqwest::header::HeaderValue::from_static("application/json"),
443        );
444        headers.insert(
445            reqwest::header::HOST,
446            reqwest::header::HeaderValue::from_static(API_HOST),
447        );
448        let constants = get_constants(1);
449        headers.insert(
450            reqwest::header::USER_AGENT,
451            reqwest::header::HeaderValue::from_static(&constants.ua),
452        );
453
454        let session = reqwest::Client::builder()
455            .http2_adaptive_window(true)
456            .use_rustls_tls()
457            .cookie_provider(std::sync::Arc::clone(&cookie_store))
458            .default_headers(headers)
459            .build()
460            .map_err(ToshoClientError::BuildError)?;
461
462        let secret_token = tosho_common::generate_random_token(16);
463        let temp_config = AMConfig::new(&secret_token, "", "");
464        let android_c = get_constants(1);
465
466        let mut json_body = HashMap::new();
467        json_body.insert(
468            "i_token".to_string(),
469            serde_json::Value::String(secret_token.clone()),
470        );
471        json_body.insert(
472            "iap_product_version".to_string(),
473            serde_json::Value::Number(serde_json::Number::from(0_u32)),
474        );
475        json_body.insert("app_login".to_string(), serde_json::Value::Bool(false));
476        json_with_common(&mut json_body, android_c)?;
477
478        let req = session
479            .request(
480                reqwest::Method::POST,
481                format!("{}/iap/remainder.json", BASE_API),
482            )
483            .headers(make_header(&temp_config, android_c)?)
484            .json(&json_body)
485            .send()
486            .await?;
487
488        let results =
489            parse_json_response_failable::<APIResult<models::IAPRemainder>, BasicWrapStatus>(req)
490                .await?;
491        let result = results.result().body().ok_or_else(|| {
492            make_error!(
493                "Failed to get remainder, got empty response: {:#?}",
494                results
495            )
496        })?;
497
498        // Step 2: Perform login
499        let mut json_body_login = HashMap::new();
500        json_body_login.insert("email".to_string(), serde_json::Value::String(email.into()));
501        json_body_login.insert(
502            "citi_pass".to_string(),
503            serde_json::Value::String(password.into()),
504        );
505        json_body_login.insert(
506            "iap_token".to_string(),
507            serde_json::Value::String(secret_token.clone()),
508        );
509        json_with_common(&mut json_body_login, android_c)?;
510
511        let temp_config = AMConfig::new(&secret_token, result.info().guest_id(), "");
512
513        let req = session
514            .request(
515                reqwest::Method::POST,
516                format!("{}/{}", BASE_API, MASKED_LOGIN),
517            )
518            .headers(make_header(&temp_config, android_c)?)
519            .json(&json_body_login)
520            .send()
521            .await?;
522
523        let results = parse_json_response::<APIResult<models::LoginResult>>(req).await?;
524        let result = results
525            .result()
526            .body()
527            .ok_or_else(|| ToshoAuthError::InvalidCredentials("Got empty response".to_string()))?;
528
529        // final step: get session_v2
530        let mut json_body_session = HashMap::new();
531        json_body_session.insert(
532            "i_token".to_string(),
533            serde_json::Value::String(secret_token.clone()),
534        );
535        json_body_session.insert(
536            "iap_product_version".to_string(),
537            serde_json::Value::Number(serde_json::Number::from(0_u32)),
538        );
539        json_body_session.insert("app_login".to_string(), serde_json::Value::Bool(true));
540        json_with_common(&mut json_body_session, android_c)?;
541
542        let temp_config = AMConfig::new(&secret_token, result.info().guest_id(), "");
543
544        let req = session
545            .request(
546                reqwest::Method::POST,
547                format!("{}/iap/remainder.json", BASE_API),
548            )
549            .headers(make_header(&temp_config, android_c)?)
550            .json(&json_body_session)
551            .send()
552            .await?;
553
554        if req.status() != reqwest::StatusCode::OK {
555            return Err(tosho_common::ToshoError::from(req.status()));
556        }
557
558        // session_v2 is cookies
559        let mut session_v2 = String::new();
560        let cookie_name = SESSION_COOKIE_NAME.to_string();
561        for cookie in cookie_store.lock().unwrap().iter_any() {
562            if cookie.name() == cookie_name {
563                session_v2 = cookie.value().to_string();
564                break;
565            }
566        }
567
568        if session_v2.is_empty() {
569            return Err(ToshoAuthError::UnknownSession.into());
570        }
571
572        Ok(AMConfig::new(
573            &secret_token,
574            result.info().guest_id(),
575            &session_v2,
576        ))
577    }
578}
579
580#[derive(Debug, Clone, serde::Deserialize)]
581struct BasicWrapStatus {
582    result: StatusResult,
583}
584
585impl FailableResponse for BasicWrapStatus {
586    fn format_error(&self) -> String {
587        self.result.format_error()
588    }
589
590    fn raise_for_status(&self) -> ToshoResult<()> {
591        self.result.raise_for_status()
592    }
593}
594
595/// Create the request headers used for the API.
596fn make_header(
597    config: &AMConfig,
598    constants: &constants::Constants,
599) -> ToshoResult<reqwest::header::HeaderMap> {
600    let mut req_headers = reqwest::header::HeaderMap::new();
601
602    let current_unix = chrono::Utc::now().timestamp();
603    let av = format!("{}/{}", APP_NAME, constants.version);
604    let formulae = format!("{}{}{}", config.token(), current_unix, av);
605
606    let formulae_hashed = <Sha256 as Digest>::digest(formulae.as_bytes());
607    let formulae_hashed = format!("{formulae_hashed:x}");
608
609    req_headers.insert(
610        HEADER_NAMES.s,
611        formulae_hashed
612            .parse()
613            .map_err(|e| make_error!("Failed to parse custom hash into header value: {}", e))?,
614    );
615    if !config.identifier().is_empty() {
616        req_headers.insert(
617            HEADER_NAMES.i,
618            config
619                .identifier()
620                .parse()
621                .map_err(|e| make_error!("Failed to parse identifier into header value: {}", e))?,
622        );
623    }
624    req_headers.insert(
625        HEADER_NAMES.n,
626        current_unix.to_string().parse().map_err(|e| {
627            make_error!(
628                "Failed to parse current unix timestamp into header value: {}",
629                e
630            )
631        })?,
632    );
633    req_headers.insert(
634        HEADER_NAMES.t,
635        config
636            .token()
637            .parse()
638            .map_err(|e| make_error!("Failed to parse token into header value: {}", e))?,
639    );
640
641    Ok(req_headers)
642}
643
644fn json_with_common(
645    json_obj: &mut HashMap<String, serde_json::Value>,
646    constants: &constants::Constants,
647) -> ToshoResult<()> {
648    let platform = constants.platform.to_string();
649    let version = constants.version.to_string();
650    let app_name = APP_NAME.to_string();
651
652    json_obj.insert("app_name".to_string(), serde_json::Value::String(app_name));
653    json_obj.insert("platform".to_string(), serde_json::Value::String(platform));
654    json_obj.insert("version".to_string(), serde_json::Value::String(version));
655
656    let mut screen = serde_json::Map::new();
657    screen.insert(
658        "inch".to_string(),
659        serde_json::Value::Number(
660            serde_json::Number::from_f64(SCREEN_INCH)
661                .ok_or_else(|| make_error!("Failed to convert screen inch to f64"))?,
662        ),
663    );
664    json_obj.insert("screen".to_string(), serde_json::Value::Object(screen));
665
666    Ok(())
667}