1#![warn(missing_docs, clippy::empty_docs, rustdoc::broken_intra_doc_links)]
2#![doc = include_str!("../README.md")]
3
4use constants::{
5 API_HOST, BASE_API, DATA_APP_ID, HEADER_PIECE, LIB_VERSION, SJ_APP_ID, VALUE_PIECE, VM_APP_ID,
6};
7use futures_util::TryStreamExt;
8use models::{
9 AccountEntitlementsResponse, AccountLoginResponse, MangaAuthResponse, MangaDetail,
10 MangaReadMetadataResponse, MangaSeriesResponse, MangaStoreInfo, MangaStoreResponse,
11 MangaUrlResponse, SimpleResponse,
12};
13use std::collections::HashMap;
14use tokio::io::{self, AsyncWriteExt};
15use tosho_common::{
16 ToshoAuthError, ToshoClientError, ToshoError, ToshoResult, bail_on_error, make_error,
17 parse_json_response, parse_json_response_failable,
18};
19
20pub mod config;
21pub mod constants;
22pub mod imaging;
23pub mod models;
24
25pub use config::*;
26
27#[derive(Clone, Debug)]
42pub struct SJClient {
43 inner: reqwest::Client,
44 config: SJConfig,
45 constants: &'static crate::constants::Constants,
46 mode: SJMode,
47}
48
49impl SJClient {
50 pub fn new(config: SJConfig, mode: SJMode) -> ToshoResult<Self> {
56 Self::make_client(config, mode, None)
57 }
58
59 pub fn with_proxy(&self, proxy: reqwest::Proxy) -> ToshoResult<Self> {
66 Self::make_client(self.config.clone(), self.mode, Some(proxy))
67 }
68
69 fn make_client(
70 config: SJConfig,
71 mode: SJMode,
72 proxy: Option<reqwest::Proxy>,
73 ) -> ToshoResult<Self> {
74 let constants = crate::constants::get_constants(config.platform() as u8);
75 let mut headers = reqwest::header::HeaderMap::new();
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 let referer = match mode {
85 SJMode::VM => &constants.vm_name,
86 SJMode::SJ => &constants.sj_name,
87 };
88 headers.insert(
89 reqwest::header::REFERER,
90 reqwest::header::HeaderValue::from_static(referer),
91 );
92
93 let x_header = format!("{} {}", constants.app_ver, VALUE_PIECE);
94 headers.insert(
95 reqwest::header::HeaderName::from_static(HEADER_PIECE),
96 reqwest::header::HeaderValue::from_str(&x_header).map_err(|_| {
97 ToshoClientError::HeaderParseError(format!("Header piece of {}", &x_header))
98 })?,
99 );
100
101 let client = reqwest::Client::builder()
102 .http2_adaptive_window(true)
103 .use_rustls_tls()
104 .default_headers(headers);
105
106 let client = match proxy {
107 Some(proxy) => client
108 .proxy(proxy)
109 .build()
110 .map_err(ToshoClientError::BuildError),
111 None => client.build().map_err(ToshoClientError::BuildError),
112 }?;
113
114 Ok(Self {
115 inner: client,
116 config,
117 constants,
118 mode,
119 })
120 }
121
122 pub fn get_mode(&self) -> SJMode {
124 self.mode
125 }
126
127 pub fn get_platform(&self) -> SJPlatform {
129 self.config.platform()
130 }
131
132 async fn request<T>(
143 &self,
144 method: reqwest::Method,
145 endpoint: &str,
146 data: Option<HashMap<String, String>>,
147 params: Option<HashMap<String, String>>,
148 ) -> ToshoResult<T>
149 where
150 T: serde::de::DeserializeOwned,
151 {
152 let endpoint = format!("{}{}", BASE_API, endpoint);
153
154 let request = match (data.clone(), params.clone()) {
155 (None, None) => self.inner.request(method, endpoint),
156 (Some(data), None) => {
157 let mut extend_headers = reqwest::header::HeaderMap::new();
158 extend_headers.insert(
159 reqwest::header::CONTENT_TYPE,
160 reqwest::header::HeaderValue::from_static("application/x-www-form-urlencoded"),
161 );
162 self.inner
163 .request(method, endpoint)
164 .form(&data)
165 .headers(extend_headers)
166 }
167 (None, Some(params)) => self.inner.request(method, endpoint).query(¶ms),
168 (Some(_), Some(_)) => {
169 bail_on_error!("Cannot have both data and params")
170 }
171 };
172
173 parse_json_response_failable::<T, SimpleResponse>(request.send().await?).await
174 }
175
176 pub async fn get_store_cache(&self) -> ToshoResult<MangaStoreResponse> {
180 let app_id = match self.mode {
181 SJMode::VM => VM_APP_ID,
182 SJMode::SJ => SJ_APP_ID,
183 };
184 let endpoint = format!(
185 "/manga/store_cached/{}/{}/{}",
186 app_id, self.constants.device_id, LIB_VERSION
187 );
188
189 let response = self
190 .request::<MangaStoreResponse>(reqwest::Method::GET, &endpoint, None, None)
191 .await?;
192
193 Ok(response)
194 }
195
196 pub async fn get_manga(&self, manga_ids: Vec<u32>) -> ToshoResult<Vec<MangaDetail>> {
201 let response = self.get_store_cache().await?;
202
203 let manga_lists: Vec<MangaDetail> = response
204 .contents()
205 .iter()
206 .filter_map(|info| match info {
207 MangaStoreInfo::Manga(manga) => {
208 if manga_ids.contains(&manga.id()) {
209 Some(manga.clone())
210 } else {
211 None
212 }
213 }
214 _ => None,
215 })
216 .collect();
217
218 Ok(manga_lists)
219 }
220
221 pub async fn get_chapters(&self, id: u32) -> ToshoResult<MangaSeriesResponse> {
226 let app_id = match self.mode {
227 SJMode::VM => VM_APP_ID,
228 SJMode::SJ => SJ_APP_ID,
229 };
230 let endpoint = format!(
231 "/manga/store/series/{}/{}/{}/{}",
232 id, app_id, self.constants.device_id, LIB_VERSION
233 );
234
235 let response = self
236 .request::<MangaSeriesResponse>(reqwest::Method::GET, &endpoint, None, None)
237 .await?;
238
239 Ok(response)
240 }
241
242 pub async fn verify_chapter(&self, id: u32) -> ToshoResult<()> {
247 let mut data = common_data_hashmap(self.constants, &self.mode, Some(&self.config));
248 data.insert("manga_id".to_string(), id.to_string());
249
250 self.request::<MangaAuthResponse>(reqwest::Method::POST, "/manga/auth", Some(data), None)
251 .await?;
252
253 Ok(())
254 }
255
256 pub async fn get_manga_url(
265 &self,
266 id: u32,
267 metadata: bool,
268 page: Option<u32>,
269 ) -> ToshoResult<String> {
270 let mut data = common_data_hashmap(self.constants, &self.mode, Some(&self.config));
271 data.insert("manga_id".to_string(), id.to_string());
272
273 match (metadata, page) {
274 (true, _) => {
275 data.insert("metadata".to_string(), "1".to_string());
276 }
277 (false, Some(page)) => {
278 data.insert("page".to_string(), page.to_string());
279 }
280 (false, None) => {
281 bail_on_error!("You must set either metadata or page!");
282 }
283 }
284
285 match self.config.platform() {
286 SJPlatform::Web => {
287 let response = self
289 .inner
290 .post(format!("{}/manga/get_manga_url", BASE_API))
291 .form(&data)
292 .send()
293 .await?;
294
295 if !response.status().is_success() {
296 bail_on_error!("Failed to get manga URL: {}", response.status());
297 }
298
299 let url = response.text().await?;
300 Ok(url)
301 }
302 _ => {
303 let resp = self
304 .request::<MangaUrlResponse>(
305 reqwest::Method::POST,
306 "/manga/get_manga_url",
307 Some(data),
308 None,
309 )
310 .await?;
311
312 if let Some(url) = resp.url() {
313 Ok(url.to_string())
314 } else if let Some(url) = resp.metadata() {
315 Ok(url.to_string())
316 } else {
317 bail_on_error!("No URL or metadata found")
318 }
319 }
320 }
321 }
322
323 pub async fn get_chapter_metadata(&self, id: u32) -> ToshoResult<MangaReadMetadataResponse> {
328 let response = self.get_manga_url(id, true, None).await?;
329 let url_parse = reqwest::Url::parse(&response)
330 .map_err(|e| make_error!("Failed to parse URL: {} ({})", response, e))?;
331 let host = url_parse
332 .host_str()
333 .ok_or_else(|| make_error!("Failed to get host from URL: {}", url_parse.as_str()))?;
334
335 let metadata_resp = self
336 .inner
337 .get(response)
338 .header(
339 reqwest::header::HOST,
340 reqwest::header::HeaderValue::from_str(host)
341 .map_err(|_| ToshoClientError::HeaderParseError(format!("Host for {host}")))?,
342 )
343 .send()
344 .await?;
345
346 let metadata: MangaReadMetadataResponse = parse_json_response(metadata_resp).await?;
347
348 Ok(metadata)
349 }
350
351 pub async fn get_entitlements(&self) -> ToshoResult<AccountEntitlementsResponse> {
355 let data = common_data_hashmap(self.constants, &self.mode, Some(&self.config));
356
357 let response = self
358 .request::<AccountEntitlementsResponse>(
359 reqwest::Method::POST,
360 "/manga/entitled",
361 Some(data),
362 None,
363 )
364 .await?;
365
366 Ok(response)
367 }
368
369 pub async fn stream_download<T: AsRef<str>>(
377 &self,
378 url: T,
379 mut writer: impl io::AsyncWrite + Unpin,
380 ) -> ToshoResult<()> {
381 let url = url.as_ref();
382 let url_parse = reqwest::Url::parse(url)
383 .map_err(|e| make_error!("Failed to parse URL: {} ({})", url, e))?;
384 let host = url_parse
385 .host_str()
386 .ok_or_else(|| make_error!("Failed to get host from URL: {}", url_parse.as_str()))?;
387
388 let res = self
389 .inner
390 .get(url)
391 .header(
392 reqwest::header::HOST,
393 reqwest::header::HeaderValue::from_str(host)
394 .map_err(|_| ToshoClientError::HeaderParseError(format!("Host for {host}")))?,
395 )
396 .send()
397 .await?;
398
399 if !res.status().is_success() {
400 Err(ToshoError::from(res.status()))
401 } else {
402 match self.config.platform() {
403 SJPlatform::Web => {
404 let image_bytes = res.bytes().await?;
405 let descrambled = tokio::task::spawn_blocking(move || {
406 crate::imaging::descramble_image(&image_bytes)
407 })
408 .await
409 .map_err(|e| make_error!("Failed to execute blocking task: {}", e))?;
410
411 match descrambled {
412 Ok(descrambled) => {
413 writer.write_all(&descrambled).await?;
414 }
415 Err(e) => return Err(e),
416 }
417
418 Ok(())
419 }
420 _ => {
421 let mut stream = res.bytes_stream();
422 while let Some(item) = stream.try_next().await? {
423 writer.write_all(&item).await?;
424 writer.flush().await?;
425 }
426 Ok(())
427 }
428 }
429 }
430 }
431
432 pub async fn login<T: Into<String>>(
442 email: T,
443 password: T,
444 mode: SJMode,
445 platform: SJPlatform,
446 ) -> ToshoResult<(AccountLoginResponse, String)> {
447 let const_plat = match platform {
448 SJPlatform::Android => 1_u8,
449 SJPlatform::Apple => 2,
450 SJPlatform::Web => 3,
451 };
452
453 let constants = crate::constants::get_constants(const_plat);
454 let mut headers = reqwest::header::HeaderMap::new();
455 headers.insert(
456 reqwest::header::USER_AGENT,
457 reqwest::header::HeaderValue::from_static(constants.ua),
458 );
459 headers.insert(
460 reqwest::header::HOST,
461 reqwest::header::HeaderValue::from_static(API_HOST),
462 );
463 let referer = match mode {
464 SJMode::VM => &constants.vm_name,
465 SJMode::SJ => &constants.sj_name,
466 };
467 headers.insert(
468 reqwest::header::REFERER,
469 reqwest::header::HeaderValue::from_static(referer),
470 );
471
472 let x_header = format!("{} {}", constants.app_ver, VALUE_PIECE);
473 headers.insert(
474 reqwest::header::HeaderName::from_static(HEADER_PIECE),
475 reqwest::header::HeaderValue::from_str(&x_header).map_err(|_| {
476 ToshoClientError::HeaderParseError(format!("Header piece of {}", &x_header))
477 })?,
478 );
479
480 let client = reqwest::Client::builder()
481 .http2_adaptive_window(true)
482 .use_rustls_tls()
483 .default_headers(headers)
484 .build()
485 .map_err(ToshoClientError::BuildError)?;
486
487 let mut data = common_data_hashmap(constants, &mode, None);
488 data.insert("login".to_string(), email.into());
489 data.insert("pass".to_string(), password.into());
490
491 let instance_id = match data.get("instance_id") {
492 Some(instance) => instance.clone(),
493 None => {
494 return Err(ToshoAuthError::CommonError(
495 "Unable to get instance_id from common_data_hashmap".to_string(),
496 )
497 .into());
498 }
499 };
500
501 let response = client
502 .post(format!("{}/manga/try_manga_login", BASE_API))
503 .form(&data)
504 .header(
505 reqwest::header::CONTENT_TYPE,
506 reqwest::header::HeaderValue::from_static("application/x-www-form-urlencoded"),
507 )
508 .send()
509 .await?;
510
511 let account_resp: AccountLoginResponse = parse_json_response(response).await?;
512
513 Ok((account_resp, instance_id))
514 }
515}
516
517fn common_data_hashmap(
518 constants: &'static crate::constants::Constants,
519 mode: &SJMode,
520 config: Option<&SJConfig>,
521) -> HashMap<String, String> {
522 let mut data: HashMap<String, String> = HashMap::new();
523 let app_id = match mode {
524 SJMode::VM => VM_APP_ID,
525 SJMode::SJ => SJ_APP_ID,
526 };
527 if let Some(config) = config {
528 data.insert("trust_user_jwt".to_string(), config.token().to_string());
529 data.insert("user_id".to_string(), config.user_id().to_string());
530 data.insert("instance_id".to_string(), config.instance().to_string());
531 data.insert("device_token".to_string(), config.instance().to_string());
532 } else {
533 data.insert(
534 "instance_id".to_string(),
535 tosho_common::generate_random_token(16),
536 );
537 }
538 data.insert("device_id".to_string(), constants.device_id.to_string());
539 data.insert("version".to_string(), LIB_VERSION.to_string());
540 data.insert(DATA_APP_ID.to_string(), app_id.to_string());
541 if let Some(version_body) = &constants.version_body {
542 data.insert(version_body.to_string(), constants.app_ver.to_string());
543 }
544 data
545}