tosho_mplus/
lib.rs

1#![warn(missing_docs, clippy::empty_docs, rustdoc::broken_intra_doc_links)]
2#![doc = include_str!("../README.md")]
3
4pub mod constants;
5pub mod helper;
6pub mod proto;
7
8use futures_util::TryStreamExt;
9use tokio::io::{self, AsyncWriteExt};
10use tosho_common::{
11    ToshoClientError, ToshoError, ToshoParseError, ToshoResult, bail_on_error,
12    parse_protobuf_response,
13};
14
15use constants::{API_HOST, Constants, IMAGE_HOST};
16use helper::RankingType;
17use proto::{CommentList, ErrorResponse, Language, SuccessOrError};
18
19use crate::constants::BASE_API;
20pub use crate::helper::ImageQuality;
21
22/// Main client for interacting with the M+ API.
23///
24/// # Example
25/// ```rust,no_run
26/// use tosho_mplus::MPClient;
27/// use tosho_mplus::proto::Language;
28/// use tosho_mplus::constants::get_constants;
29///
30/// #[tokio::main]
31/// async fn main() {
32///     let client = MPClient::new("1234", Language::English, get_constants(1)).unwrap();
33///     let home_view = client.get_home_page().await.unwrap();
34/// }
35/// ```
36///
37/// # Boxed
38///
39/// All responses are [`Box`]-ed since it has widely varying sizes.
40#[derive(Clone, Debug)]
41pub struct MPClient {
42    inner: reqwest::Client,
43    secret: String,
44    language: Language,
45    constants: &'static Constants,
46    app_ver: Option<u32>,
47}
48
49impl MPClient {
50    /// Create a new client instance.
51    ///
52    /// # Parameters
53    /// * `secret` - The secret key to use for the client.
54    /// * `language` - The language to use for the client.
55    /// * `constants` - The constants to use for the client.
56    pub fn new(
57        secret: impl Into<String>,
58        language: Language,
59        constants: &'static Constants,
60    ) -> ToshoResult<Self> {
61        Self::make_client(secret, language, constants, None)
62    }
63
64    /// Attach a proxy to the client.
65    ///
66    /// This will clone the client and return a new client with the proxy attached.
67    ///
68    /// # Arguments
69    /// * `proxy` - The proxy to attach to the client
70    pub fn with_proxy(&self, proxy: reqwest::Proxy) -> ToshoResult<Self> {
71        Self::make_client(&self.secret, self.language, self.constants, Some(proxy))
72    }
73
74    /// Override the app version for the client.
75    ///
76    /// This will clone the client and return a new client with the app version overridden.
77    ///
78    /// # Arguments
79    /// * `app_ver` - The app version to use for the client.
80    pub fn with_app_version(&self, app_ver: Option<u32>) -> Self {
81        let mut new_client = self.clone();
82        new_client.app_ver = app_ver;
83        new_client
84    }
85
86    fn make_client(
87        secret: impl Into<String>,
88        language: Language,
89        constants: &'static Constants,
90        proxy: Option<reqwest::Proxy>,
91    ) -> ToshoResult<Self> {
92        let mut headers = reqwest::header::HeaderMap::new();
93        headers.insert("Host", reqwest::header::HeaderValue::from_static(API_HOST));
94        headers.insert(
95            "User-Agent",
96            reqwest::header::HeaderValue::from_static(&constants.api_ua),
97        );
98
99        let client = reqwest::Client::builder()
100            .http2_adaptive_window(true)
101            .http1_only()
102            .use_rustls_tls()
103            .default_headers(headers);
104
105        let client = match proxy {
106            Some(proxy) => client
107                .proxy(proxy)
108                .build()
109                .map_err(ToshoClientError::BuildError),
110            None => client.build().map_err(ToshoClientError::BuildError),
111        }?;
112
113        Ok(Self {
114            inner: client,
115            secret: secret.into(),
116            language,
117            constants,
118            app_ver: None,
119        })
120    }
121
122    /// Modify the HashMap to add the required parameters.
123    fn build_params(&self, params: &mut Vec<(String, String)>, with_lang: bool) {
124        if with_lang {
125            params.push((
126                "lang".to_string(),
127                self.language.as_language_code().to_owned(),
128            ));
129            params.push((
130                "clang".to_string(),
131                self.language.as_language_code().to_owned(),
132            ));
133        }
134        params.push(("os".to_string(), self.constants.os_name.to_string()));
135        params.push(("os_ver".to_string(), self.constants.os_ver.to_string()));
136        params.push((
137            "app_ver".to_string(),
138            if let Some(app_ver) = self.app_ver {
139                app_ver.to_string()
140            } else {
141                self.constants.app_ver.to_string()
142            },
143        ));
144        params.push(("secret".to_string(), self.secret.clone()));
145    }
146
147    fn build_url(&self, path: &str) -> String {
148        if path.starts_with('/') {
149            return format!("{}{}", BASE_API, path);
150        }
151
152        format!("{}/{}", BASE_API, path)
153    }
154
155    fn empty_params(&self, with_lang: bool) -> Vec<(String, String)> {
156        let mut params: Vec<(String, String)> = vec![];
157
158        self.build_params(&mut params, with_lang);
159
160        params
161    }
162
163    /// Get the initial view of the app.
164    pub async fn get_initial(&self) -> ToshoResult<APIResponse<proto::InitialViewV2>> {
165        let request = self
166            .inner
167            .get(self.build_url("init_v2"))
168            .query(&self.empty_params(false))
169            .send()
170            .await?;
171
172        let response = parse_response(request).await?;
173
174        match response {
175            SuccessOrError::Success(data) => match data.initial_view_v2() {
176                Some(inner_data) => Ok(APIResponse::Success(Box::new(inner_data))),
177                None => Err(ToshoParseError::expect("initial view")),
178            },
179            SuccessOrError::Error(error) => Ok(APIResponse::Error(error)),
180        }
181    }
182
183    /// Get the main home view of the app.
184    pub async fn get_home_page(&self) -> ToshoResult<APIResponse<proto::HomeViewV3>> {
185        let mut query_params = self.empty_params(true);
186        query_params.insert(0, ("viewer_mode".to_string(), "horizontal".to_string()));
187
188        let request = self
189            .inner
190            .get(self.build_url("home_v4"))
191            .query(&query_params)
192            .send()
193            .await?;
194
195        let response = parse_response(request).await?;
196
197        match response {
198            SuccessOrError::Success(data) => match data.home_view_v3() {
199                Some(inner_data) => Ok(APIResponse::Success(Box::new(inner_data))),
200                None => Err(ToshoParseError::expect("home view v3")),
201            },
202            SuccessOrError::Error(error) => Ok(APIResponse::Error(error)),
203        }
204    }
205
206    /// Get the user profile
207    pub async fn get_user_profile(&self) -> ToshoResult<APIResponse<proto::UserProfileSettings>> {
208        let query = self.empty_params(false);
209        let request = self
210            .inner
211            .get(self.build_url("profile"))
212            .query(&query)
213            .send()
214            .await?;
215
216        let response = parse_response(request).await?;
217
218        match response {
219            SuccessOrError::Success(data) => match data.user_profile_settings() {
220                Some(inner_data) => Ok(APIResponse::Success(Box::new(inner_data))),
221                None => Err(ToshoParseError::expect("user profile settings")),
222            },
223            SuccessOrError::Error(error) => Ok(APIResponse::Error(error)),
224        }
225    }
226
227    /// Get the user settings.
228    pub async fn get_user_settings(&self) -> ToshoResult<APIResponse<proto::UserSettingsV2>> {
229        let mut query_params = self.empty_params(true);
230        query_params.insert(0, ("viewer_mode".to_string(), "horizontal".to_string()));
231
232        let request = self
233            .inner
234            .get(self.build_url("settings_v2"))
235            .query(&query_params)
236            .send()
237            .await?;
238
239        let response = parse_response(request).await?;
240
241        match response {
242            SuccessOrError::Success(data) => match data.user_settings_v2() {
243                Some(inner_data) => Ok(APIResponse::Success(Box::new(inner_data))),
244                None => Err(ToshoParseError::expect("user settings v2")),
245            },
246            SuccessOrError::Error(error) => Ok(APIResponse::Error(error)),
247        }
248    }
249
250    /// Get the subscriptions list and details.
251    pub async fn get_subscriptions(&self) -> ToshoResult<APIResponse<proto::SubscriptionResponse>> {
252        let request = self
253            .inner
254            .get(self.build_url("subscription"))
255            .query(&self.empty_params(false))
256            .send()
257            .await?;
258
259        let response = parse_response(request).await?;
260
261        match response {
262            SuccessOrError::Success(data) => match data.subscriptions() {
263                Some(inner_data) => Ok(APIResponse::Success(Box::new(inner_data))),
264                None => Err(ToshoParseError::expect("subscriptions")),
265            },
266            SuccessOrError::Error(error) => Ok(APIResponse::Error(error)),
267        }
268    }
269
270    /// Get all the available titles.
271    pub async fn get_all_titles(&self) -> ToshoResult<APIResponse<proto::TitleListOnlyV2>> {
272        let request = self
273            .inner
274            .get(self.build_url("title_list/all_v2"))
275            .query(&self.empty_params(false))
276            .send()
277            .await?;
278
279        let response = parse_response(request).await?;
280
281        match response {
282            SuccessOrError::Success(data) => match data.all_titles_v2() {
283                Some(inner_data) => Ok(APIResponse::Success(Box::new(inner_data))),
284                None => Err(ToshoParseError::expect("all titles v2")),
285            },
286            SuccessOrError::Error(error) => Ok(APIResponse::Error(error)),
287        }
288    }
289
290    /// Get title ranking list.
291    ///
292    /// # Arguments
293    /// * `kind` - The type of ranking to get.
294    pub async fn get_title_ranking(
295        &self,
296        kind: Option<RankingType>,
297    ) -> ToshoResult<APIResponse<proto::TitleRankingList>> {
298        let kind = kind.unwrap_or(RankingType::Hottest);
299        let mut query_params = self.empty_params(true);
300        query_params.insert(0, ("type".to_string(), kind.to_string()));
301
302        let request = self
303            .inner
304            .get(self.build_url("title_list/rankingV2"))
305            .query(&query_params)
306            .send()
307            .await?;
308
309        let response = parse_response(request).await?;
310
311        match response {
312            SuccessOrError::Success(data) => match data.title_ranking_v2() {
313                Some(inner_data) => Ok(APIResponse::Success(Box::new(inner_data))),
314                None => Err(ToshoParseError::expect("title ranking v2")),
315            },
316            SuccessOrError::Error(error) => Ok(APIResponse::Error(error)),
317        }
318    }
319
320    /// Get all free titles
321    pub async fn get_free_titles(&self) -> ToshoResult<APIResponse<proto::FreeTitles>> {
322        let request = self
323            .inner
324            .get(self.build_url("title_list/free_titles"))
325            .query(&self.empty_params(false))
326            .send()
327            .await?;
328
329        let response = parse_response(request).await?;
330
331        match response {
332            SuccessOrError::Success(data) => match data.free_titles() {
333                Some(inner_data) => Ok(APIResponse::Success(Box::new(inner_data))),
334                None => Err(ToshoParseError::expect("free titles")),
335            },
336            SuccessOrError::Error(error) => Ok(APIResponse::Error(error)),
337        }
338    }
339
340    /// Get the bookmarked titles
341    pub async fn get_bookmarked_titles(&self) -> ToshoResult<APIResponse<proto::TitleListOnly>> {
342        let request = self
343            .inner
344            .get(self.build_url("title_list/bookmark"))
345            .query(&self.empty_params(false))
346            .send()
347            .await?;
348
349        let response = parse_response(request).await?;
350
351        match response {
352            SuccessOrError::Success(data) => match data.subscribed_titles() {
353                Some(inner_data) => Ok(APIResponse::Success(Box::new(inner_data))),
354                None => Err(ToshoParseError::expect("subscribed/bookmarked titles")),
355            },
356            SuccessOrError::Error(error) => Ok(APIResponse::Error(error)),
357        }
358    }
359
360    /// Get list of titles for specific language
361    ///
362    /// Internally, this use the "search" API which does not take any
363    /// query information for some unknown reason.
364    pub async fn get_search(&self) -> ToshoResult<APIResponse<proto::SearchResults>> {
365        let request = self
366            .inner
367            .get(self.build_url("title_list/search"))
368            .query(&self.empty_params(true))
369            .send()
370            .await?;
371
372        let response = parse_response(request).await?;
373
374        match response {
375            SuccessOrError::Success(data) => match data.search_results() {
376                Some(inner_data) => Ok(APIResponse::Success(Box::new(inner_data))),
377                None => Err(ToshoParseError::expect("search results")),
378            },
379            SuccessOrError::Error(error) => Ok(APIResponse::Error(error)),
380        }
381    }
382
383    /// Get detailed information about a title.
384    ///
385    /// # Arguments
386    /// * `title_id` - The ID of the title to get information about.
387    pub async fn get_title_details(
388        &self,
389        title_id: u64,
390    ) -> ToshoResult<APIResponse<proto::TitleDetail>> {
391        let mut query_params = self.empty_params(true);
392        query_params.insert(0, ("title_id".to_string(), title_id.to_string()));
393
394        let request = self
395            .inner
396            .get(self.build_url("title_detailV3"))
397            .query(&query_params)
398            .send()
399            .await?;
400
401        let response = parse_response(request).await?;
402
403        match response {
404            SuccessOrError::Success(data) => match data.title_detail() {
405                Some(inner_data) => {
406                    let mut cloned_data = inner_data.clone();
407                    cloned_data
408                        .chapter_groups_mut()
409                        .iter_mut()
410                        .for_each(|group| {
411                            group
412                                .first_chapters_mut()
413                                .iter_mut()
414                                .for_each(|ch| ch.set_position(proto::ChapterPosition::First));
415
416                            group
417                                .last_chapters_mut()
418                                .iter_mut()
419                                .for_each(|ch| ch.set_position(proto::ChapterPosition::Last));
420
421                            group
422                                .mid_chapters_mut()
423                                .iter_mut()
424                                .for_each(|ch| ch.set_position(proto::ChapterPosition::Middle));
425                        });
426
427                    Ok(APIResponse::Success(Box::new(cloned_data)))
428                }
429                None => Err(ToshoParseError::expect("title_detail")),
430            },
431            SuccessOrError::Error(error) => Ok(APIResponse::Error(error)),
432        }
433    }
434
435    /// Get chapter viewer information.
436    ///
437    /// # Arguments
438    /// * `chapter` - The chapter to get information about.
439    /// * `title` - The title of the chapter.
440    /// * `quality` - The quality of the image to get.
441    /// * `split` - Whether to split the image spread or not.
442    pub async fn get_chapter_viewer(
443        &self,
444        chapter: &proto::Chapter,
445        title: &proto::TitleDetail,
446        quality: ImageQuality,
447        split: bool,
448    ) -> ToshoResult<APIResponse<proto::ChapterViewer>> {
449        let mut query_params = vec![];
450        query_params.push(("chapter_id".to_string(), chapter.chapter_id().to_string()));
451        query_params.push((
452            "split".to_string(),
453            if split { "yes" } else { "no" }.to_string(),
454        ));
455        query_params.push(("img_quality".to_string(), quality.to_string()));
456        query_params.push((
457            "viewer_mode".to_string(),
458            chapter.default_view_mode().to_string(),
459        ));
460        // Determine the way to read the chapter
461        if chapter.is_free() {
462            query_params.push(("free_reading".to_string(), "yes".to_string()));
463            query_params.push(("subscription_reading".to_string(), "no".to_string()));
464            query_params.push(("ticket_reading".to_string(), "no".to_string()));
465        } else if chapter.is_ticketed() {
466            query_params.push(("ticket_reading".to_string(), "yes".to_string()));
467            query_params.push(("free_reading".to_string(), "no".to_string()));
468            query_params.push(("subscription_reading".to_string(), "no".to_string()));
469        } else {
470            let user_sub = title.user_subscription().cloned().unwrap_or_default();
471            let title_labels = title.title_labels().cloned().unwrap_or_default();
472            if user_sub.plan() >= title_labels.plan_type() {
473                query_params.push(("subscription_reading".to_string(), "yes".to_string()));
474                query_params.push(("ticket_reading".to_string(), "no".to_string()));
475                query_params.push(("free_reading".to_string(), "no".to_string()));
476            } else {
477                bail_on_error!(
478                    "Chapter is not free and user does not have minimum subscription: {:?} < {:?}",
479                    user_sub.plan(),
480                    title_labels.plan_type()
481                );
482            }
483        }
484        self.build_params(&mut query_params, false);
485
486        let request = self
487            .inner
488            .get(self.build_url("manga_viewer"))
489            .query(&query_params)
490            .send()
491            .await?;
492
493        let response = parse_response(request).await?;
494
495        match response {
496            SuccessOrError::Success(data) => match data.chapter_viewer() {
497                Some(inner_data) => Ok(APIResponse::Success(Box::new(inner_data))),
498                None => Err(ToshoParseError::expect("chapter viewer")),
499            },
500            SuccessOrError::Error(error) => Ok(APIResponse::Error(error)),
501        }
502    }
503
504    /// Get comments for a chapter
505    ///
506    /// # Parameters
507    /// * `id` - The ID of the chapter to get comments for.
508    pub async fn get_comments(&self, id: u64) -> ToshoResult<APIResponse<CommentList>> {
509        let mut query_params = self.empty_params(false);
510        query_params.insert(0, ("chapter_id".to_string(), id.to_string()));
511
512        let request = self
513            .inner
514            .get(self.build_url("comments"))
515            .query(&query_params)
516            .send()
517            .await?;
518
519        let response = parse_response(request).await?;
520
521        match response {
522            SuccessOrError::Success(data) => match data.comment_list() {
523                Some(inner_data) => Ok(APIResponse::Success(Box::new(inner_data))),
524                None => Err(ToshoParseError::expect("comment list")),
525            },
526            SuccessOrError::Error(error) => Ok(APIResponse::Error(error)),
527        }
528    }
529
530    /// Stream download the image from the given URL.
531    ///
532    /// The URL can be obtained from [`get_chapter_images`](#method.get_chapter_images).
533    ///
534    /// # Parameters
535    /// * `url` - The URL to download the image from.
536    /// * `writer` - The writer to write the image to.
537    pub async fn stream_download(
538        &self,
539        url: impl AsRef<str>,
540        mut writer: impl io::AsyncWrite + Unpin,
541    ) -> ToshoResult<()> {
542        let res = self
543            .inner
544            .get(url.as_ref())
545            .headers({
546                let mut headers = reqwest::header::HeaderMap::new();
547                headers.insert(
548                    "Host",
549                    reqwest::header::HeaderValue::from_static(IMAGE_HOST),
550                );
551                headers.insert(
552                    "User-Agent",
553                    reqwest::header::HeaderValue::from_static(&self.constants.image_ua),
554                );
555                headers.insert(
556                    "Cache-Control",
557                    reqwest::header::HeaderValue::from_static("no-cache"),
558                );
559                headers
560            })
561            .send()
562            .await?;
563
564        // bail if not success
565        if !res.status().is_success() {
566            Err(ToshoError::from(res.status()))
567        } else {
568            let mut stream = res.bytes_stream();
569            while let Some(item) = stream.try_next().await? {
570                writer.write_all(&item).await?;
571                writer.flush().await?;
572            }
573
574            Ok(())
575        }
576    }
577}
578
579/// A common return type for all API calls.
580///
581/// It either returns the specified success response or an error.
582pub enum APIResponse<T: ::prost::Message + Clone> {
583    /// A [`Box`]-ed [`ErrorResponse`]
584    Error(Box<ErrorResponse>),
585    /// Successfull response, also [`Box`]-ed and depends on the API call
586    Success(Box<T>),
587}
588
589// impl unwrap for APIResponse
590impl<T: ::prost::Message + Clone> APIResponse<T> {
591    /// Unwrap the response.
592    ///
593    /// # Panics
594    /// Panics if the response is an error.
595    pub fn unwrap(self) -> T {
596        match self {
597            APIResponse::Success(data) => *data,
598            APIResponse::Error(error) => panic!("Error response: {:?}", *error),
599        }
600    }
601}
602
603/// A quick wrapper for [`parse_protobuf_response`]
604async fn parse_response(res: reqwest::Response) -> ToshoResult<SuccessOrError> {
605    let decoded_response = parse_protobuf_response::<crate::proto::Response>(res).await?;
606
607    // oneof response on .response
608    match decoded_response.response() {
609        Some(response) => Ok(response),
610        None => Err(tosho_common::ToshoParseError::empty()),
611    }
612}