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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
579pub enum APIResponse<T: ::prost::Message + Clone> {
583 Error(Box<ErrorResponse>),
585 Success(Box<T>),
587}
588
589impl<T: ::prost::Message + Clone> APIResponse<T> {
591 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
603async fn parse_response(res: reqwest::Response) -> ToshoResult<SuccessOrError> {
605 let decoded_response = parse_protobuf_response::<crate::proto::Response>(res).await?;
606
607 match decoded_response.response() {
609 Some(response) => Ok(response),
610 None => Err(tosho_common::ToshoParseError::empty()),
611 }
612}