tosho_sjv/
lib.rs

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/// Main client for interacting with the SJ/M API.
28///
29/// # Examples
30/// ```rust,no_run
31/// use tosho_sjv::{SJClient, SJConfig, SJMode, SJPlatform};
32///
33/// #[tokio::main]
34/// async fn main() {
35///     let config = SJConfig::new(123, "xyz987abc", "abcxyz", SJPlatform::Android);
36///     let client = SJClient::new(config, SJMode::VM).unwrap();
37///     let manga = client.get_manga(vec![777]).await.unwrap();
38///     println!("{:?}", manga);
39/// }
40/// ```
41#[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    /// Create a new client instance.
51    ///
52    /// # Parameters
53    /// * `config` - The configuration to use for the client.
54    /// * `mode` - The mode to use for the client.
55    pub fn new(config: SJConfig, mode: SJMode) -> ToshoResult<Self> {
56        Self::make_client(config, mode, None)
57    }
58
59    /// Attach a proxy to the client.
60    ///
61    /// This will clone the client and return a new client with the proxy attached.
62    ///
63    /// # Arguments
64    /// * `proxy` - The proxy to attach to the client
65    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    /// Return the mode of the client.
123    pub fn get_mode(&self) -> SJMode {
124        self.mode
125    }
126
127    /// Return the platform of the client.
128    pub fn get_platform(&self) -> SJPlatform {
129        self.config.platform()
130    }
131
132    /// Make an authenticated request to the API.
133    ///
134    /// This request will automatically add all the required headers/cookies/auth method
135    /// to the request.
136    ///
137    /// # Arguments
138    /// * `method` - The HTTP method to use
139    /// * `endpoint` - The endpoint to request (e.g. `/episode/list`)
140    /// * `data` - The data to send in the request body (as form data)
141    /// * `params` - The query params to send in the request
142    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(&params),
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    /// Get the manga store cache that can be use for other route.
177    ///
178    /// Can be used to get every possible manga series.
179    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    /// Get the list of manga from the given list of manga IDs
197    ///
198    /// # Arguments
199    /// * `manga_ids` - The list of manga IDs to get
200    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    /// Get list of chapters for specific manga ID
222    ///
223    /// # Arguments
224    /// * `id` - The manga ID
225    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    /// Check if specific chapter can be viewed by us.
243    ///
244    /// # Arguments
245    /// * `id` - The chapter ID
246    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    /// Get manga URL for specific chapter ID
257    ///
258    /// # Arguments
259    /// * `id` - The chapter ID
260    /// * `metadata` - Fetch metadata
261    /// * `page` - Fetch specific page
262    ///
263    /// Metadata fetch will take precedent
264    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                // web didn't return JSON response but direct URL
288                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    /// Get metadata for a chapter
324    ///
325    /// # Arguments
326    /// * `id` - The chapter ID
327    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    /// Get the current user entitlements.
352    ///
353    /// This contains subscription information and other details.
354    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    /// Stream download the image from the given URL.
370    ///
371    /// The URL can be obtained from [`SJClient::get_manga_url`].
372    ///
373    /// # Parameters
374    /// * `url` - The URL to download the image from.
375    /// * `writer` - The writer to write the image to.
376    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    /// Perform a login request.
433    ///
434    /// Compared to other source crate, this method return the original response
435    /// instead of the parsed config.
436    ///
437    /// # Arguments
438    /// * `email` - The email of the user.
439    /// * `password` - The password of the user.
440    /// * `mode` - The mode to use for the login.
441    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}