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#[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 pub fn new(config: AMConfig) -> ToshoResult<Self> {
59 Self::make_client(config, None)
60 }
61
62 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
595fn 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}