1use crate::error::{PixivError, Result};
2use crate::models::app::{
3 Comment, CommentsResponse, ContentType, Duration, Filter, FollowRestrict, IllustBookmarkResponse,
4 IllustDetail, IllustFollowResponse, RankingMode, RankingResponse, RecommendedResponse,
5 SearchIllustResponse, SearchTarget, Sort, TrendingTagsResponse, UgoiraMetadataResponse,
6 UserFollowingResponse, UserFollowerResponse, UserMypixivResponse,
7};
8use crate::network::HttpClient;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use tracing::debug;
12
13#[derive(Debug, Clone)]
15pub struct AppClient {
16 http_client: HttpClient,
18 base_url: String,
20}
21
22impl AppClient {
23 pub fn new(http_client: HttpClient) -> Self {
25 Self {
26 http_client,
27 base_url: "https://app-api.pixiv.net".to_string(),
28 }
29 }
30
31 pub fn set_base_url(&mut self, url: String) {
33 self.base_url = url;
34 }
35
36 pub fn base_url(&self) -> &str {
38 &self.base_url
39 }
40
41 pub async fn illust_detail(&self, illust_id: u64) -> Result<IllustDetail> {
55 debug!(illust_id = %illust_id, "Fetching illustration detail");
56
57 let url = format!("{}/v1/illust/detail", self.base_url);
58 let params = [("illust_id", illust_id.to_string())];
59
60 let response = self
61 .http_client
62 .send_request(reqwest::Method::GET, &url, Some(¶ms))
63 .await?;
64
65 let text = response.text().await?;
66 let detail: IllustDetail = serde_json::from_str(&text)?;
67
68 Ok(detail)
69 }
70
71 pub async fn illust_ranking(
93 &self,
94 mode: RankingMode,
95 filter: Filter,
96 date: Option<&str>,
97 offset: Option<u32>,
98 ) -> Result<RankingResponse> {
99 debug!(
100 mode = %mode.to_string(),
101 filter = %filter.to_string(),
102 date = ?date,
103 offset = ?offset,
104 "Fetching illustration ranking"
105 );
106
107 let url = format!("{}/v1/illust/ranking", self.base_url);
108 let mut params = Vec::new();
109 params.push(("mode", mode.to_string()));
110 params.push(("filter", filter.to_string()));
111
112 if let Some(date) = date {
113 params.push(("date", date.to_string()));
114 }
115
116 if let Some(offset) = offset {
117 params.push(("offset", offset.to_string()));
118 }
119
120 let response = self
121 .http_client
122 .send_request(reqwest::Method::GET, &url, Some(¶ms))
123 .await?;
124
125 let text = response.text().await?;
126 let ranking: RankingResponse = serde_json::from_str(&text)?;
127
128 Ok(ranking)
129 }
130
131 pub async fn illust_recommended(
163 &self,
164 content_type: ContentType,
165 include_ranking_label: bool,
166 filter: Filter,
167 max_bookmark_id_for_recommend: Option<u64>,
168 min_bookmark_id_for_recent_illust: Option<u64>,
169 offset: Option<u32>,
170 include_ranking_illusts: Option<bool>,
171 bookmark_illust_ids: Option<Vec<u64>>,
172 viewed: Option<Vec<String>>,
173 ) -> Result<RecommendedResponse> {
174 debug!(
175 content_type = %content_type.to_string(),
176 include_ranking_label = %include_ranking_label,
177 filter = %filter.to_string(),
178 max_bookmark_id_for_recommend = ?max_bookmark_id_for_recommend,
179 min_bookmark_id_for_recent_illust = ?min_bookmark_id_for_recent_illust,
180 offset = ?offset,
181 include_ranking_illusts = ?include_ranking_illusts,
182 bookmark_illust_ids = ?bookmark_illust_ids,
183 viewed = ?viewed,
184 "Fetching recommended illustrations"
185 );
186
187 let url = format!("{}/v1/illust/recommended", self.base_url);
188 let mut params = Vec::new();
189 params.push(("content_type".to_string(), content_type.to_string()));
190 params.push(("include_ranking_label".to_string(), include_ranking_label.to_string()));
191 params.push(("filter".to_string(), filter.to_string()));
192
193 if let Some(max_bookmark_id_for_recommend) = max_bookmark_id_for_recommend {
194 params.push(("max_bookmark_id_for_recommend".to_string(), max_bookmark_id_for_recommend.to_string()));
195 }
196
197 if let Some(min_bookmark_id_for_recent_illust) = min_bookmark_id_for_recent_illust {
198 params.push(("min_bookmark_id_for_recent_illust".to_string(), min_bookmark_id_for_recent_illust.to_string()));
199 }
200
201 if let Some(offset) = offset {
202 params.push(("offset".to_string(), offset.to_string()));
203 }
204
205 if let Some(include_ranking_illusts) = include_ranking_illusts {
206 params.push(("include_ranking_illusts".to_string(), include_ranking_illusts.to_string()));
207 }
208
209 if let Some(bookmark_illust_ids) = bookmark_illust_ids {
210 let ids = bookmark_illust_ids
211 .iter()
212 .map(|id| id.to_string())
213 .collect::<Vec<_>>()
214 .join(",");
215 params.push(("bookmark_illust_ids".to_string(), ids));
216 }
217
218 if let Some(viewed) = viewed {
219 for (i, viewed_id) in viewed.iter().enumerate() {
220 let key = format!("viewed[{}]", i);
221 params.push((key, viewed_id.to_string()));
222 }
223 }
224
225 let response = self
226 .http_client
227 .send_request(reqwest::Method::GET, &url, Some(¶ms))
228 .await?;
229
230 let text = response.text().await?;
231 let recommended: RecommendedResponse = serde_json::from_str(&text)?;
232
233 Ok(recommended)
234 }
235
236 pub async fn search_illust(
268 &self,
269 word: &str,
270 search_target: SearchTarget,
271 sort: Sort,
272 duration: Option<Duration>,
273 start_date: Option<&str>,
274 end_date: Option<&str>,
275 filter: Filter,
276 search_ai_type: Option<u32>,
277 offset: Option<u32>,
278 ) -> Result<SearchIllustResponse> {
279 debug!(
280 word = %word,
281 search_target = %search_target.to_string(),
282 sort = %sort.to_string(),
283 duration = ?duration,
284 start_date = ?start_date,
285 end_date = ?end_date,
286 filter = %filter.to_string(),
287 search_ai_type = ?search_ai_type,
288 offset = ?offset,
289 "Searching illustrations"
290 );
291
292 let url = format!("{}/v1/search/illust", self.base_url);
293 let mut params = Vec::new();
294 params.push(("word", word.to_string()));
295 params.push(("search_target", search_target.to_string()));
296 params.push(("sort", sort.to_string()));
297 params.push(("filter", filter.to_string()));
298
299 if let Some(duration) = duration {
300 params.push(("duration", duration.to_string()));
301 }
302
303 if let Some(start_date) = start_date {
304 params.push(("start_date", start_date.to_string()));
305 }
306
307 if let Some(end_date) = end_date {
308 params.push(("end_date", end_date.to_string()));
309 }
310
311 if let Some(search_ai_type) = search_ai_type {
312 params.push(("search_ai_type", search_ai_type.to_string()));
313 }
314
315 if let Some(offset) = offset {
316 params.push(("offset", offset.to_string()));
317 }
318
319 let response = self
320 .http_client
321 .send_request(reqwest::Method::GET, &url, Some(¶ms))
322 .await?;
323
324 let text = response.text().await?;
325 let search_result: SearchIllustResponse = serde_json::from_str(&text)?;
326
327 Ok(search_result)
328 }
329
330 pub async fn illust_follow(
348 &self,
349 restrict: FollowRestrict,
350 offset: Option<u32>,
351 ) -> Result<IllustFollowResponse> {
352 debug!(
353 restrict = %restrict.to_string(),
354 offset = ?offset,
355 "Fetching follow illustrations"
356 );
357
358 let url = format!("{}/v2/illust/follow", self.base_url);
359 let mut params = Vec::new();
360 params.push(("restrict", restrict.to_string()));
361
362 if let Some(offset) = offset {
363 params.push(("offset", offset.to_string()));
364 }
365
366 let response = self
367 .http_client
368 .send_request(reqwest::Method::GET, &url, Some(¶ms))
369 .await?;
370
371 let text = response.text().await?;
372 let follow_response: IllustFollowResponse = serde_json::from_str(&text)?;
373
374 Ok(follow_response)
375 }
376
377 pub async fn illust_comments(
397 &self,
398 illust_id: u64,
399 offset: Option<u32>,
400 include_total_comments: Option<bool>,
401 ) -> Result<CommentsResponse> {
402 debug!(
403 illust_id = %illust_id,
404 offset = ?offset,
405 include_total_comments = ?include_total_comments,
406 "Fetching illustration comments"
407 );
408
409 let url = format!("{}/v1/illust/comments", self.base_url);
410 let mut params = Vec::new();
411 params.push(("illust_id", illust_id.to_string()));
412
413 if let Some(offset) = offset {
414 params.push(("offset", offset.to_string()));
415 }
416
417 if let Some(include_total_comments) = include_total_comments {
418 params.push(("include_total_comments", include_total_comments.to_string()));
419 }
420
421 let response = self
422 .http_client
423 .send_request(reqwest::Method::GET, &url, Some(¶ms))
424 .await?;
425
426 let text = response.text().await?;
427 let comments: CommentsResponse = serde_json::from_str(&text)?;
428
429 Ok(comments)
430 }
431
432 pub async fn user_following(
452 &self,
453 user_id: u64,
454 restrict: FollowRestrict,
455 offset: Option<u32>,
456 ) -> Result<UserFollowingResponse> {
457 debug!(
458 user_id = %user_id,
459 restrict = %restrict.to_string(),
460 offset = ?offset,
461 "Fetching user following"
462 );
463
464 let url = format!("{}/v1/user/following", self.base_url);
465 let mut params = Vec::new();
466 params.push(("user_id", user_id.to_string()));
467 params.push(("restrict", restrict.to_string()));
468
469 if let Some(offset) = offset {
470 params.push(("offset", offset.to_string()));
471 }
472
473 let response = self
474 .http_client
475 .send_request(reqwest::Method::GET, &url, Some(¶ms))
476 .await?;
477
478 let text = response.text().await?;
479 let following: UserFollowingResponse = serde_json::from_str(&text)?;
480
481 Ok(following)
482 }
483
484 pub async fn user_follower(
504 &self,
505 user_id: u64,
506 filter: Filter,
507 offset: Option<u32>,
508 ) -> Result<UserFollowerResponse> {
509 debug!(
510 user_id = %user_id,
511 filter = %filter.to_string(),
512 offset = ?offset,
513 "Fetching user followers"
514 );
515
516 let url = format!("{}/v1/user/follower", self.base_url);
517 let mut params = Vec::new();
518 params.push(("user_id", user_id.to_string()));
519 params.push(("filter", filter.to_string()));
520
521 if let Some(offset) = offset {
522 params.push(("offset", offset.to_string()));
523 }
524
525 let response = self
526 .http_client
527 .send_request(reqwest::Method::GET, &url, Some(¶ms))
528 .await?;
529
530 let text = response.text().await?;
531 let followers: UserFollowerResponse = serde_json::from_str(&text)?;
532
533 Ok(followers)
534 }
535
536 pub async fn user_mypixiv(
554 &self,
555 user_id: u64,
556 offset: Option<u32>,
557 ) -> Result<UserMypixivResponse> {
558 debug!(
559 user_id = %user_id,
560 offset = ?offset,
561 "Fetching user mypixiv"
562 );
563
564 let url = format!("{}/v1/user/mypixiv", self.base_url);
565 let mut params = Vec::new();
566 params.push(("user_id", user_id.to_string()));
567
568 if let Some(offset) = offset {
569 params.push(("offset", offset.to_string()));
570 }
571
572 let response = self
573 .http_client
574 .send_request(reqwest::Method::GET, &url, Some(¶ms))
575 .await?;
576
577 let text = response.text().await?;
578 let mypixiv: UserMypixivResponse = serde_json::from_str(&text)?;
579
580 Ok(mypixiv)
581 }
582
583 pub async fn illust_bookmark_add(
603 &self,
604 illust_id: u64,
605 restrict: FollowRestrict,
606 tags: Option<Vec<String>>,
607 ) -> Result<IllustBookmarkResponse> {
608 debug!(
609 illust_id = %illust_id,
610 restrict = %restrict.to_string(),
611 tags = ?tags,
612 "Adding illustration bookmark"
613 );
614
615 let url = format!("{}/v2/illust/bookmark/add", self.base_url);
616 let mut data = HashMap::new();
617 data.insert("illust_id", illust_id.to_string());
618 data.insert("restrict", restrict.to_string());
619
620 if let Some(tags) = tags {
621 data.insert("tags", tags.join(" "));
622 }
623
624 let response = self
625 .http_client
626 .send_request(reqwest::Method::POST, &url, Some(&data))
627 .await?;
628
629 let text = response.text().await?;
630 let bookmark_response: IllustBookmarkResponse = serde_json::from_str(&text)?;
631
632 Ok(bookmark_response)
633 }
634
635 pub async fn illust_bookmark_delete(
649 &self,
650 illust_id: u64,
651 ) -> Result<IllustBookmarkResponse> {
652 debug!(
653 illust_id = %illust_id,
654 "Deleting illustration bookmark"
655 );
656
657 let url = format!("{}/v1/illust/bookmark/delete", self.base_url);
658 let mut data = HashMap::new();
659 data.insert("illust_id", illust_id.to_string());
660
661 let response = self
662 .http_client
663 .send_request(reqwest::Method::POST, &url, Some(&data))
664 .await?;
665
666 let text = response.text().await?;
667 let bookmark_response: IllustBookmarkResponse = serde_json::from_str(&text)?;
668
669 Ok(bookmark_response)
670 }
671
672 pub async fn trending_tags_illust(
686 &self,
687 filter: Filter,
688 ) -> Result<TrendingTagsResponse> {
689 debug!(
690 filter = %filter.to_string(),
691 "Fetching trending tags"
692 );
693
694 let url = format!("{}/v1/trending-tags/illust", self.base_url);
695 let params = [("filter", filter.to_string())];
696
697 let response = self
698 .http_client
699 .send_request(reqwest::Method::GET, &url, Some(¶ms))
700 .await?;
701
702 let text = response.text().await?;
703 let trending: TrendingTagsResponse = serde_json::from_str(&text)?;
704
705 Ok(trending)
706 }
707
708 pub async fn ugoira_metadata(
722 &self,
723 illust_id: u64,
724 ) -> Result<UgoiraMetadataResponse> {
725 debug!(
726 illust_id = %illust_id,
727 "Fetching ugoira metadata"
728 );
729
730 let url = format!("{}/v1/ugoira/metadata", self.base_url);
731 let params = [("illust_id", illust_id.to_string())];
732
733 let response = self
734 .http_client
735 .send_request(reqwest::Method::GET, &url, Some(¶ms))
736 .await?;
737
738 let text = response.text().await?;
739 let metadata: UgoiraMetadataResponse = serde_json::from_str(&text)?;
740
741 Ok(metadata)
742 }
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748
749 #[test]
750 fn test_search_target_to_string() {
751 assert_eq!(SearchTarget::PartialMatchForTags.to_string(), "partial_match_for_tags");
752 assert_eq!(SearchTarget::ExactMatchForTags.to_string(), "exact_match_for_tags");
753 assert_eq!(SearchTarget::TitleAndCaption.to_string(), "title_and_caption");
754 assert_eq!(SearchTarget::Keyword.to_string(), "keyword");
755 }
756
757 #[test]
758 fn test_sort_to_string() {
759 assert_eq!(Sort::DateDesc.to_string(), "date_desc");
760 assert_eq!(Sort::DateAsc.to_string(), "date_asc");
761 assert_eq!(Sort::PopularDesc.to_string(), "popular_desc");
762 }
763
764 #[test]
765 fn test_ranking_mode_to_string() {
766 assert_eq!(RankingMode::Day.to_string(), "day");
767 assert_eq!(RankingMode::Week.to_string(), "week");
768 assert_eq!(RankingMode::Month.to_string(), "month");
769 assert_eq!(RankingMode::DayMale.to_string(), "day_male");
770 assert_eq!(RankingMode::DayFemale.to_string(), "day_female");
771 assert_eq!(RankingMode::WeekOriginal.to_string(), "week_original");
772 assert_eq!(RankingMode::WeekRookie.to_string(), "week_rookie");
773 assert_eq!(RankingMode::DayManga.to_string(), "day_manga");
774 assert_eq!(RankingMode::DayR18.to_string(), "day_r18");
775 assert_eq!(RankingMode::DayMaleR18.to_string(), "day_male_r18");
776 assert_eq!(RankingMode::DayFemaleR18.to_string(), "day_female_r18");
777 assert_eq!(RankingMode::WeekR18.to_string(), "week_r18");
778 assert_eq!(RankingMode::WeekR18g.to_string(), "week_r18g");
779 }
780
781 #[test]
782 fn test_content_type_to_string() {
783 assert_eq!(ContentType::Illust.to_string(), "illust");
784 assert_eq!(ContentType::Manga.to_string(), "manga");
785 }
786
787 #[test]
788 fn test_filter_to_string() {
789 assert_eq!(Filter::ForIOS.to_string(), "for_ios");
790 assert_eq!(Filter::None.to_string(), "");
791 }
792
793 #[test]
794 fn test_duration_to_string() {
795 assert_eq!(Duration::WithinLastDay.to_string(), "within_last_day");
796 assert_eq!(Duration::WithinLastWeek.to_string(), "within_last_week");
797 assert_eq!(Duration::WithinLastMonth.to_string(), "within_last_month");
798 }
799}